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 | 8 | staticfiles | 8 | staticfiles |
6 | 9 | lib/versioninfo.py | 9 | lib/versioninfo.py |
7 | 10 | settings.py | 10 | settings.py |
8 | 11 | rsakeys/* | ||
9 | 11 | 12 | ||
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 | 45 | 2. Install system dependencies | 45 | 2. Install system dependencies |
15 | 46 | :: | 46 | :: |
16 | 47 | 47 | ||
17 | 48 | # Needed for libsodium package | ||
18 | 49 | $ sudo apt-get install -y software-properties-common | ||
19 | 50 | $ sudo add-apt-repository ppa:facundo/salty | ||
20 | 51 | |||
21 | 48 | $ sudo apt-get update | 52 | $ sudo apt-get update |
22 | 49 | $ cat dependencies.txt | sudo xargs apt-get install -y --no-install-recommends | 53 | $ cat dependencies.txt | sudo xargs apt-get install -y --no-install-recommends |
23 | 50 | $ cat dependencies-devel.txt | sudo xargs apt-get install -y --no-install-recommends | 54 | $ cat dependencies-devel.txt | sudo xargs apt-get install -y --no-install-recommends |
24 | @@ -207,6 +211,21 @@ | |||
25 | 207 | lxc with no internet access at all. | 211 | lxc with no internet access at all. |
26 | 208 | 212 | ||
27 | 209 | 213 | ||
28 | 214 | 13. (Optional) Set up private/public keys to make macaroons work between | ||
29 | 215 | projects | ||
30 | 216 | |||
31 | 217 | For some endpoints to work correctly, the system needs to decrypt keys | ||
32 | 218 | that were encrypted from other services (you'll need to use this | ||
33 | 219 | instructions, or the corresponding one in the other projects). | ||
34 | 220 | |||
35 | 221 | So, create a pair of keys for this project: | ||
36 | 222 | |||
37 | 223 | ssh-keygen -t rsa -N "" -f project_id_rsa | ||
38 | 224 | |||
39 | 225 | This will leave you with two files, move the private one into the | ||
40 | 226 | ``rsakeys`` directory, and the .pub one into this same dir in the | ||
41 | 227 | other projects. | ||
42 | 228 | |||
43 | 210 | 229 | ||
44 | 211 | BAZAAR | 230 | BAZAAR |
45 | 212 | ------ | 231 | ------ |
46 | 213 | 232 | ||
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 | 1 | bzr | 1 | bzr |
52 | 2 | geoip-database | 2 | geoip-database |
53 | 3 | libgeoip1 | 3 | libgeoip1 |
54 | 4 | libsodium13 | ||
55 | 4 | python-amqplib | 5 | python-amqplib |
56 | 5 | python-bcrypt | 6 | python-bcrypt |
57 | 6 | python-beautifulsoup | 7 | python-beautifulsoup |
58 | 7 | 8 | ||
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 | 3 | import random | 3 | import random |
64 | 4 | from urlparse import urlparse | 4 | from urlparse import urlparse |
65 | 5 | 5 | ||
66 | 6 | from Crypto.PublicKey import RSA | ||
67 | 7 | |||
68 | 6 | from versioninfo import version_info | 8 | from versioninfo import version_info |
69 | 7 | 9 | ||
70 | 8 | # /srv/login.staging.ubuntu.com/staging/canonical-identity-provider | 10 | # /srv/login.staging.ubuntu.com/staging/canonical-identity-provider |
71 | @@ -68,6 +70,16 @@ | |||
72 | 68 | COMBINE = True | 70 | COMBINE = True |
73 | 69 | COMBO_URL = '/combo/' | 71 | COMBO_URL = '/combo/' |
74 | 70 | COMMENTS_ALLOW_PROFANITIES = False | 72 | COMMENTS_ALLOW_PROFANITIES = False |
75 | 73 | _fpath = os.path.join( | ||
76 | 74 | os.path.abspath(os.getenv('SCA_RSAKEYS_DIR', '')), 'sso_id_rsa') | ||
77 | 75 | try: | ||
78 | 76 | with open(_fpath) as fh: | ||
79 | 77 | raw = fh.read() | ||
80 | 78 | CRYPTO_SSO_PRIVKEY = RSA.importKey(raw) | ||
81 | 79 | except: | ||
82 | 80 | # this catch-all-and-set-None will be removed after first successful | ||
83 | 81 | # deployments having the proper key in place | ||
84 | 82 | CRYPTO_SSO_PRIVKEY = None | ||
85 | 71 | CSRF_COOKIE_DOMAIN = None | 83 | CSRF_COOKIE_DOMAIN = None |
86 | 72 | CSRF_COOKIE_HTTPONLY = True | 84 | CSRF_COOKIE_HTTPONLY = True |
87 | 73 | CSRF_COOKIE_NAME = 'csrftoken' | 85 | CSRF_COOKIE_NAME = 'csrftoken' |
88 | @@ -368,6 +380,8 @@ | |||
89 | 368 | LOGIN_REDIRECT_URL = '/' | 380 | LOGIN_REDIRECT_URL = '/' |
90 | 369 | LOGIN_URL = '/+login' | 381 | LOGIN_URL = '/+login' |
91 | 370 | LOGOUT_URL = '/accounts/logout/' | 382 | LOGOUT_URL = '/accounts/logout/' |
92 | 383 | MACAROON_TTL = 365 * 24 * 3600 # seconds | ||
93 | 384 | MACAROON_SERVICE_LOCATION = HOSTNAME | ||
94 | 371 | MANAGERS = [] | 385 | MANAGERS = [] |
95 | 372 | MAX_FAILED_LOGIN_ATTEMPTS = 20 | 386 | MAX_FAILED_LOGIN_ATTEMPTS = 20 |
96 | 373 | MAX_PASSWORD_RESET_TOKENS = 5 | 387 | MAX_PASSWORD_RESET_TOKENS = 5 |
97 | 374 | 388 | ||
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 | 1 | import os | 1 | import os |
103 | 2 | 2 | ||
107 | 3 | os.environ.setdefault( | 3 | BASE_DIR = os.path.dirname(os.path.dirname(__file__)) |
108 | 4 | 'SSO_LOGS_DIR', | 4 | os.environ.setdefault('SSO_LOGS_DIR', os.path.join(BASE_DIR, 'logs')) |
109 | 5 | os.path.join(os.path.dirname(os.path.dirname(__file__)), 'logs')) | 5 | os.environ.setdefault('SCA_RSAKEYS_DIR', os.path.join(BASE_DIR, 'rsakeys')) |
110 | 6 | os.environ.setdefault('SSO_ROOT_URL', 'http://0.0.0.0:8000') | 6 | os.environ.setdefault('SSO_ROOT_URL', 'http://0.0.0.0:8000') |
111 | 7 | 7 | ||
112 | 8 | from django_project.settings_base import * # noqa | 8 | from django_project.settings_base import * # noqa |
113 | 9 | 9 | ||
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 | 12 | gargoyle==0.11.0 | 12 | gargoyle==0.11.0 |
119 | 13 | gunicorn==19.3.0 | 13 | gunicorn==19.3.0 |
120 | 14 | lazr.authentication==0.1.3 | 14 | lazr.authentication==0.1.3 |
121 | 15 | libnacl==1.3.6 | ||
122 | 15 | nexus==0.3.1 | 16 | nexus==0.3.1 |
123 | 16 | oath==1.4.0 | 17 | oath==1.4.0 |
124 | 17 | oauthlib==0.7.2 | 18 | oauthlib==0.7.2 |
125 | @@ -22,8 +23,10 @@ | |||
126 | 22 | oops-timeline==0.0.2 | 23 | oops-timeline==0.0.2 |
127 | 23 | oops-wsgi==0.0.11 | 24 | oops-wsgi==0.0.11 |
128 | 24 | paste==2.0.1 | 25 | paste==2.0.1 |
129 | 26 | pymacaroons==0.9.1 | ||
130 | 25 | raven==5.6.0 | 27 | raven==5.6.0 |
131 | 26 | requests-oauthlib==0.4.2 | 28 | requests-oauthlib==0.4.2 |
132 | 29 | six==1.10.0 | ||
133 | 27 | statsd==3.1 | 30 | statsd==3.1 |
134 | 28 | testresources==0.2.7 | 31 | testresources==0.2.7 |
135 | 29 | timeline==0.0.4 | 32 | timeline==0.0.4 |
136 | 30 | 33 | ||
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 | 7 | pygments==2.0.2 | 7 | pygments==2.0.2 |
142 | 8 | pytz==2014.10 | 8 | pytz==2014.10 |
143 | 9 | setuptools==14.0 | 9 | setuptools==14.0 |
144 | 10 | six==1.9.0 | ||
145 | 11 | snowballstemmer==1.2.0 | 10 | snowballstemmer==1.2.0 |
146 | 12 | sphinx==1.3 | 11 | sphinx==1.3 |
147 | 13 | sphinx-bootstrap-theme==0.4.5 | 12 | sphinx-bootstrap-theme==0.4.5 |
148 | 14 | 13 | ||
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 @@ | |||
155 | 1 | # Copyright 2010-2013 Canonical Ltd. This software is licensed under the | 1 | # Copyright 2010-2016 Canonical Ltd. This software is licensed under the |
156 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). |
157 | 3 | 3 | ||
158 | 4 | import logging | 4 | import logging |
159 | @@ -406,6 +406,79 @@ | |||
160 | 406 | return response | 406 | return response |
161 | 407 | 407 | ||
162 | 408 | 408 | ||
163 | 409 | class MacaroonHandler(BaseHandler): | ||
164 | 410 | allowed_methods = ('POST',) | ||
165 | 411 | |||
166 | 412 | @require_mime('json') | ||
167 | 413 | @throttle(extra_callback=get_email_from_request_data) | ||
168 | 414 | def create(self, request): | ||
169 | 415 | if settings.CRYPTO_SSO_PRIVKEY is None: | ||
170 | 416 | return errors.RESOURCE_NOT_FOUND() | ||
171 | 417 | |||
172 | 418 | data = request.data | ||
173 | 419 | try: | ||
174 | 420 | email = data['email'] | ||
175 | 421 | password = data['password'] | ||
176 | 422 | root_macaroon_raw = data['macaroon'] | ||
177 | 423 | except KeyError: | ||
178 | 424 | expected = set(('email', 'password', 'macaroon')) | ||
179 | 425 | missing = dict((k, [FIELD_REQUIRED]) for k in expected - set(data)) | ||
180 | 426 | return errors.INVALID_DATA(**missing) | ||
181 | 427 | |||
182 | 428 | account = None | ||
183 | 429 | response = None | ||
184 | 430 | try: | ||
185 | 431 | account = authenticate_user(email, password, request=request) | ||
186 | 432 | except AccountSuspended: | ||
187 | 433 | response = errors.ACCOUNT_SUSPENDED() | ||
188 | 434 | except AccountDeactivated: | ||
189 | 435 | response = errors.ACCOUNT_DEACTIVATED() | ||
190 | 436 | except EmailInvalidated: | ||
191 | 437 | response = errors.EMAIL_INVALIDATED() | ||
192 | 438 | except PasswordPolicyError as e: | ||
193 | 439 | root_url = request.build_absolute_uri('/') | ||
194 | 440 | response = errors.PASSWORD_POLICY_ERROR( | ||
195 | 441 | reason=unicode(e), location=root_url) | ||
196 | 442 | except AuthenticationError: | ||
197 | 443 | response = errors.INVALID_CREDENTIALS() | ||
198 | 444 | |||
199 | 445 | if account is not None: | ||
200 | 446 | otp = data.get('otp') | ||
201 | 447 | if otp is not None: | ||
202 | 448 | try: | ||
203 | 449 | twofactor.authenticate(account, otp) | ||
204 | 450 | except AuthenticationError: | ||
205 | 451 | account = None | ||
206 | 452 | response = errors.TWOFACTOR_FAILURE() | ||
207 | 453 | elif account.twofactor_required: | ||
208 | 454 | response = errors.TWOFACTOR_REQUIRED() | ||
209 | 455 | |||
210 | 456 | if account is None: | ||
211 | 457 | # track failed login attempt | ||
212 | 458 | login_failed.send(sender=self, request=request, | ||
213 | 459 | credentials=dict(email=email, password=password), | ||
214 | 460 | authlogtype=AuthLogType.API_FAIL) | ||
215 | 461 | |||
216 | 462 | if response is not None: | ||
217 | 463 | return response | ||
218 | 464 | |||
219 | 465 | try: | ||
220 | 466 | discharge = auth.build_discharge_macaroon( | ||
221 | 467 | account, root_macaroon_raw) | ||
222 | 468 | except ValidationError: | ||
223 | 469 | return errors.INVALID_DATA() | ||
224 | 470 | except AuthenticationError: | ||
225 | 471 | return errors.INVALID_CREDENTIALS() | ||
226 | 472 | |||
227 | 473 | response = rc.ALL_OK | ||
228 | 474 | response.content = dict(discharge_macaroon=discharge.serialize()) | ||
229 | 475 | |||
230 | 476 | log_type = AuthLogType.API_MACAROON_DISCHARGE_NEW | ||
231 | 477 | login_succeeded.send(sender=self, user=account, request=request, | ||
232 | 478 | authlogtype=log_type, email=email) | ||
233 | 479 | return response | ||
234 | 480 | |||
235 | 481 | |||
236 | 409 | class EmailsHandler(BaseHandler): | 482 | class EmailsHandler(BaseHandler): |
237 | 410 | allowed_methods = ('DELETE', 'GET') | 483 | allowed_methods = ('DELETE', 'GET') |
238 | 411 | model = EmailAddress | 484 | model = EmailAddress |
239 | 412 | 485 | ||
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 | 2 | 2 | ||
245 | 3 | from __future__ import unicode_literals | 3 | from __future__ import unicode_literals |
246 | 4 | 4 | ||
247 | 5 | import base64 | ||
248 | 6 | import binascii | ||
249 | 5 | import json | 7 | import json |
250 | 8 | import os | ||
251 | 6 | import re | 9 | import re |
252 | 7 | import time | 10 | import time |
253 | 8 | 11 | ||
254 | @@ -10,8 +13,10 @@ | |||
255 | 10 | from urllib import quote, urlencode | 13 | from urllib import quote, urlencode |
256 | 11 | from urlparse import parse_qsl, urlparse, urlunparse | 14 | from urlparse import parse_qsl, urlparse, urlunparse |
257 | 12 | 15 | ||
258 | 16 | from Crypto import Random | ||
259 | 17 | from Crypto.PublicKey import RSA | ||
260 | 13 | from django.conf import settings | 18 | from django.conf import settings |
262 | 14 | from django.contrib.auth.models import AnonymousUser | 19 | from django.contrib.auth.models import AnonymousUser, make_password |
263 | 15 | from django.core import mail | 20 | from django.core import mail |
264 | 16 | from django.core.serializers.json import DjangoJSONEncoder | 21 | from django.core.serializers.json import DjangoJSONEncoder |
265 | 17 | from django.core.urlresolvers import NoReverseMatch, reverse | 22 | from django.core.urlresolvers import NoReverseMatch, reverse |
266 | @@ -20,6 +25,7 @@ | |||
267 | 20 | from django.utils.timezone import now | 25 | from django.utils.timezone import now |
268 | 21 | from gargoyle.testutils import switches | 26 | from gargoyle.testutils import switches |
269 | 22 | from mock import Mock, patch | 27 | from mock import Mock, patch |
270 | 28 | from pymacaroons import Macaroon, Verifier | ||
271 | 23 | from timeline import Timeline | 29 | from timeline import Timeline |
272 | 24 | 30 | ||
273 | 25 | from api.v20 import handlers, whitelist | 31 | from api.v20 import handlers, whitelist |
274 | @@ -34,15 +40,17 @@ | |||
275 | 34 | from identityprovider.models.const import ( | 40 | from identityprovider.models.const import ( |
276 | 35 | AccountCreationRationale, | 41 | AccountCreationRationale, |
277 | 36 | AccountStatus, | 42 | AccountStatus, |
278 | 43 | AuthLogType, | ||
279 | 37 | AuthTokenType, | 44 | AuthTokenType, |
280 | 38 | EmailStatus, | 45 | EmailStatus, |
281 | 39 | TokenScope, | 46 | TokenScope, |
282 | 40 | ) | 47 | ) |
283 | 48 | from identityprovider.signals import login_failed | ||
284 | 41 | from identityprovider.tests import DEFAULT_USER_PASSWORD | 49 | from identityprovider.tests import DEFAULT_USER_PASSWORD |
286 | 42 | from identityprovider.tests.utils import SSOBaseTestCase | 50 | from identityprovider.tests.test_auth import AuthLogTestCaseMixin |
287 | 51 | from identityprovider.tests.utils import SSOBaseTestCase, TimelineActionMixin | ||
288 | 43 | from identityprovider.utils import redirection_url_for_token | 52 | from identityprovider.utils import redirection_url_for_token |
289 | 44 | 53 | ||
290 | 45 | |||
291 | 46 | OVERRIDES = dict( | 54 | OVERRIDES = dict( |
292 | 47 | EMAIL_WHITELIST_REGEXP_LIST=['^canonicaltest(?:\+.+)?@gmail\.com$'] | 55 | EMAIL_WHITELIST_REGEXP_LIST=['^canonicaltest(?:\+.+)?@gmail\.com$'] |
293 | 48 | ) | 56 | ) |
294 | @@ -2022,3 +2030,251 @@ | |||
295 | 2022 | mock_cache.get.return_value = (0, time.time() + 42.1) | 2030 | mock_cache.get.return_value = (0, time.time() + 42.1) |
296 | 2023 | self.get_response(request.path, method=request.method) | 2031 | self.get_response(request.path, method=request.method) |
297 | 2024 | self.assertEqual([], called_metrics) | 2032 | self.assertEqual([], called_metrics) |
298 | 2033 | |||
299 | 2034 | |||
300 | 2035 | class MacaroonHandlerBaseTestCase(SSOBaseTestCase): | ||
301 | 2036 | |||
302 | 2037 | url = reverse('api-macaroon-discharge') | ||
303 | 2038 | |||
304 | 2039 | def track_failed_logins(self, **kw): | ||
305 | 2040 | self.login_failed_calls.append(kw) | ||
306 | 2041 | |||
307 | 2042 | def build_macaroon(self, service_location=None): | ||
308 | 2043 | """Create a Macaron with the proper third party caveat.""" | ||
309 | 2044 | if service_location is None: | ||
310 | 2045 | service_location = settings.MACAROON_SERVICE_LOCATION | ||
311 | 2046 | |||
312 | 2047 | # pair of keys to encrypt/decrypt | ||
313 | 2048 | Random.atfork() | ||
314 | 2049 | test_rsa_priv_key = RSA.generate(1024) | ||
315 | 2050 | test_rsa_pub_key = test_rsa_priv_key.publickey() | ||
316 | 2051 | p = self.settings(CRYPTO_SSO_PRIVKEY=test_rsa_priv_key) | ||
317 | 2052 | p.enable() | ||
318 | 2053 | self.addCleanup(p.disable) | ||
319 | 2054 | |||
320 | 2055 | macaroon_random_key = binascii.hexlify(os.urandom(32)) | ||
321 | 2056 | root_macaroon = Macaroon( | ||
322 | 2057 | location='The store ;)', | ||
323 | 2058 | key=macaroon_random_key, | ||
324 | 2059 | identifier='A test macaroon', | ||
325 | 2060 | ) | ||
326 | 2061 | random_key = binascii.hexlify(os.urandom(32)) | ||
327 | 2062 | random_key_encrypted = base64.b64encode( | ||
328 | 2063 | test_rsa_pub_key.encrypt(random_key, 32)[0]) | ||
329 | 2064 | root_macaroon.add_third_party_caveat( | ||
330 | 2065 | service_location, random_key, random_key_encrypted) | ||
331 | 2066 | return root_macaroon, macaroon_random_key | ||
332 | 2067 | |||
333 | 2068 | def setUp(self): | ||
334 | 2069 | super(MacaroonHandlerBaseTestCase, self).setUp() | ||
335 | 2070 | |||
336 | 2071 | self.login_failed_calls = [] | ||
337 | 2072 | login_failed.connect(self.track_failed_logins, dispatch_uid=self.id()) | ||
338 | 2073 | |||
339 | 2074 | self.root_macaroon, self.macaroon_random_key = self.build_macaroon() | ||
340 | 2075 | self.data = dict( | ||
341 | 2076 | email='foo@bar.com', password='foobar123', | ||
342 | 2077 | macaroon=self.root_macaroon.serialize()) | ||
343 | 2078 | self.account = self.factory.make_account( | ||
344 | 2079 | email=self.data['email'], password=self.data['password']) | ||
345 | 2080 | |||
346 | 2081 | |||
347 | 2082 | class MacaroonHandlerTestCase(MacaroonHandlerBaseTestCase, | ||
348 | 2083 | AuthLogTestCaseMixin): | ||
349 | 2084 | |||
350 | 2085 | def do_post(self, data=None, expected_status_code=200, | ||
351 | 2086 | content_type='application/json', **kwargs): | ||
352 | 2087 | if data is None: | ||
353 | 2088 | data = self.data | ||
354 | 2089 | |||
355 | 2090 | response = self.client.post(self.url, data=json.dumps(data), | ||
356 | 2091 | content_type=content_type, **kwargs) | ||
357 | 2092 | |||
358 | 2093 | self.assertEqual( | ||
359 | 2094 | response.status_code, expected_status_code, | ||
360 | 2095 | "Bad status code! expected={} got={} response={}".format( | ||
361 | 2096 | expected_status_code, response.status_code, response)) | ||
362 | 2097 | self.assertEqual(response['Content-type'], | ||
363 | 2098 | 'application/json; charset=utf-8', | ||
364 | 2099 | response) | ||
365 | 2100 | return json.loads(response.content) | ||
366 | 2101 | |||
367 | 2102 | def assert_response_correct(self, json_body, account): | ||
368 | 2103 | """Check we received a good discharge macaroon for the root sent. | ||
369 | 2104 | |||
370 | 2105 | Proper verification of the received macaroon internals happens in | ||
371 | 2106 | the tests of the macaroon builder. | ||
372 | 2107 | """ | ||
373 | 2108 | discharge_macaroon_raw = json_body['discharge_macaroon'] | ||
374 | 2109 | discharge_macaroon = Macaroon.deserialize(discharge_macaroon_raw) | ||
375 | 2110 | v = Verifier() | ||
376 | 2111 | v.satisfy_general(lambda c: True) | ||
377 | 2112 | v.verify( | ||
378 | 2113 | self.root_macaroon, self.macaroon_random_key, [discharge_macaroon]) | ||
379 | 2114 | |||
380 | 2115 | def assert_failed_login(self, code, data, extra=None, | ||
381 | 2116 | expected_status_code=403, check_login_failed=True): | ||
382 | 2117 | if extra is None: | ||
383 | 2118 | extra = {} | ||
384 | 2119 | |||
385 | 2120 | json_body = self.do_post(expected_status_code=expected_status_code, | ||
386 | 2121 | data=data) | ||
387 | 2122 | self.assertEqual(json_body['code'], code) | ||
388 | 2123 | self.assertEqual(json_body['extra'], extra) | ||
389 | 2124 | |||
390 | 2125 | if check_login_failed: | ||
391 | 2126 | self.assertEqual(len(self.login_failed_calls), 1) | ||
392 | 2127 | failed = self.login_failed_calls[0] | ||
393 | 2128 | self.assertEqual( | ||
394 | 2129 | failed['credentials'], | ||
395 | 2130 | {'email': data['email'], 'password': data['password']}) | ||
396 | 2131 | |||
397 | 2132 | return json_body | ||
398 | 2133 | |||
399 | 2134 | def test_login_required_parameters(self): | ||
400 | 2135 | json_body = self.do_post(expected_status_code=400, data={}) | ||
401 | 2136 | |||
402 | 2137 | self.assertEqual(json_body, { | ||
403 | 2138 | 'code': 'INVALID_DATA', | ||
404 | 2139 | 'extra': {'email': [handlers.FIELD_REQUIRED]}, | ||
405 | 2140 | 'message': 'Invalid request data'}, | ||
406 | 2141 | ) | ||
407 | 2142 | self.assertEqual(self.login_failed_calls, []) | ||
408 | 2143 | |||
409 | 2144 | def test_account_suspended(self): | ||
410 | 2145 | self.account.suspend() | ||
411 | 2146 | self.assert_failed_login('ACCOUNT_SUSPENDED', self.data) | ||
412 | 2147 | |||
413 | 2148 | def test_account_deactivated(self): | ||
414 | 2149 | self.account.deactivate() | ||
415 | 2150 | self.assert_failed_login('ACCOUNT_DEACTIVATED', self.data) | ||
416 | 2151 | |||
417 | 2152 | def test_failed_login(self): | ||
418 | 2153 | self.account.set_password('other-thing') | ||
419 | 2154 | self.account.save() | ||
420 | 2155 | self.assert_failed_login('INVALID_CREDENTIALS', | ||
421 | 2156 | self.data, expected_status_code=401) | ||
422 | 2157 | |||
423 | 2158 | def test_email_invalidated(self): | ||
424 | 2159 | self.account.preferredemail.invalidate() | ||
425 | 2160 | self.assert_failed_login('EMAIL_INVALIDATED', self.data) | ||
426 | 2161 | |||
427 | 2162 | def test_password_policy(self): | ||
428 | 2163 | self.account.accountpassword.password = make_password('short') | ||
429 | 2164 | self.account.accountpassword.save() | ||
430 | 2165 | |||
431 | 2166 | self.data['password'] = 'short' | ||
432 | 2167 | extra = { | ||
433 | 2168 | 'reason': 'Password must be at least 8 characters long', | ||
434 | 2169 | 'location': 'http://testserver/'} | ||
435 | 2170 | self.assert_failed_login( | ||
436 | 2171 | 'PASSWORD_POLICY_ERROR', data=self.data, extra=extra) | ||
437 | 2172 | |||
438 | 2173 | def test_twofactor_required(self): | ||
439 | 2174 | self.account.twofactor_required = True | ||
440 | 2175 | self.account.save() | ||
441 | 2176 | |||
442 | 2177 | json_body = self.do_post(expected_status_code=401) | ||
443 | 2178 | self.assertEqual(json_body['code'], 'TWOFACTOR_REQUIRED') | ||
444 | 2179 | self.assertEqual(self.login_failed_calls, []) | ||
445 | 2180 | |||
446 | 2181 | def test_twofactor(self): | ||
447 | 2182 | device = self.factory.make_device(account=self.account) | ||
448 | 2183 | self.account.twofactor_required = True | ||
449 | 2184 | self.account.save() | ||
450 | 2185 | |||
451 | 2186 | self.data['otp'] = self.factory.make_next_otp(device) | ||
452 | 2187 | json_body = self.do_post(data=self.data) | ||
453 | 2188 | |||
454 | 2189 | self.assert_response_correct(json_body, self.account) | ||
455 | 2190 | |||
456 | 2191 | def test_twofactor_wrong_otp(self): | ||
457 | 2192 | device = self.factory.make_device(account=self.account) | ||
458 | 2193 | self.account.twofactor_required = True | ||
459 | 2194 | self.account.save() | ||
460 | 2195 | |||
461 | 2196 | self.data['otp'] = str(int(self.factory.make_next_otp(device)) - 1) | ||
462 | 2197 | self.assert_failed_login('TWOFACTOR_FAILURE', self.data) | ||
463 | 2198 | |||
464 | 2199 | def test_failed_login_creates_authlog(self): | ||
465 | 2200 | self.account.set_password('other-thing') | ||
466 | 2201 | self.account.save() | ||
467 | 2202 | expected_values = { | ||
468 | 2203 | 'login_email': self.data['email'], | ||
469 | 2204 | 'log_type': AuthLogType.API_FAIL, | ||
470 | 2205 | 'referer': '', | ||
471 | 2206 | 'user_agent': '', | ||
472 | 2207 | 'remote_ip': '127.0.0.1', | ||
473 | 2208 | 'account': None | ||
474 | 2209 | } | ||
475 | 2210 | with self.assert_authlog_matches(expected_values): | ||
476 | 2211 | self.assert_failed_login('INVALID_CREDENTIALS', | ||
477 | 2212 | self.data, expected_status_code=401) | ||
478 | 2213 | |||
479 | 2214 | def test_login_creates_authlog(self): | ||
480 | 2215 | expected_values = { | ||
481 | 2216 | 'login_email': self.data['email'], | ||
482 | 2217 | 'log_type': AuthLogType.API_MACAROON_DISCHARGE_NEW, | ||
483 | 2218 | 'referer': '', | ||
484 | 2219 | 'user_agent': '', | ||
485 | 2220 | 'remote_ip': '127.0.0.1', | ||
486 | 2221 | 'account': self.account.id | ||
487 | 2222 | } | ||
488 | 2223 | # This context manager magically checks when_created values | ||
489 | 2224 | with self.assert_authlog_matches(expected_values): | ||
490 | 2225 | self.do_post() | ||
491 | 2226 | |||
492 | 2227 | def test_macaroon_created(self): | ||
493 | 2228 | json_body = self.do_post() | ||
494 | 2229 | self.assert_response_correct(json_body, self.account) | ||
495 | 2230 | |||
496 | 2231 | def test_root_macaroon_corrupt(self): | ||
497 | 2232 | data = dict( | ||
498 | 2233 | email='foo@bar.com', password='foobar123', | ||
499 | 2234 | macaroon="I'm a seriously corrupted macaroon") | ||
500 | 2235 | self.assert_failed_login('INVALID_DATA', data, | ||
501 | 2236 | expected_status_code=400, | ||
502 | 2237 | check_login_failed=False) | ||
503 | 2238 | |||
504 | 2239 | def test_root_macaroon_not_for_sso(self): | ||
505 | 2240 | macaroon, _ = self.build_macaroon(service_location="other service") | ||
506 | 2241 | data = dict(email='foo@bar.com', password='foobar123', | ||
507 | 2242 | macaroon=macaroon.serialize()) | ||
508 | 2243 | self.assert_failed_login('INVALID_CREDENTIALS', data, | ||
509 | 2244 | expected_status_code=401, | ||
510 | 2245 | check_login_failed=False) | ||
511 | 2246 | |||
512 | 2247 | |||
513 | 2248 | class MacaroonHandlerTimelineTestCase(MacaroonHandlerBaseTestCase, | ||
514 | 2249 | TimelineActionMixin): | ||
515 | 2250 | |||
516 | 2251 | def test_login_timeline_records_password_checking(self): | ||
517 | 2252 | # Prepare and inject a timeline in the client's POST. | ||
518 | 2253 | empty_timeline = Timeline() | ||
519 | 2254 | response = self.client.post( | ||
520 | 2255 | self.url, data=json.dumps(self.data), | ||
521 | 2256 | content_type='application/json', | ||
522 | 2257 | **{'timeline.timeline': empty_timeline}) | ||
523 | 2258 | self.assert_timeline_contains(request=response.wsgi_request, | ||
524 | 2259 | category='django-password-checking', | ||
525 | 2260 | detail=str(self.account.id)) | ||
526 | 2261 | |||
527 | 2262 | def test_login_ignore_weird_timeline(self): | ||
528 | 2263 | """A weird object passed as timeline should be ignored/not used.""" | ||
529 | 2264 | weird_timeline = [] | ||
530 | 2265 | response = self.client.post( | ||
531 | 2266 | self.url, data=json.dumps(self.data), | ||
532 | 2267 | content_type='application/json', | ||
533 | 2268 | **{'timeline.timeline': weird_timeline}) | ||
534 | 2269 | |||
535 | 2270 | # If the timeline is a weird object we shouldn't have touched it | ||
536 | 2271 | self.assertEqual([], response.wsgi_request.META['timeline.timeline']) | ||
537 | 2272 | |||
538 | 2273 | def test_login_no_timeline(self): | ||
539 | 2274 | """Shouldn't barf if there's no timeline in the request at all.""" | ||
540 | 2275 | response = self.client.post( | ||
541 | 2276 | self.url, data=json.dumps(self.data), | ||
542 | 2277 | content_type='application/json') | ||
543 | 2278 | |||
544 | 2279 | self.assertEqual(response.status_code, 200) | ||
545 | 2280 | self.assertNotIn('timeline.timeline', response.wsgi_request.META) | ||
546 | 2025 | 2281 | ||
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 | 15 | AccountRegistrationHandler, | 15 | AccountRegistrationHandler, |
552 | 16 | AccountsHandler, | 16 | AccountsHandler, |
553 | 17 | EmailsHandler, | 17 | EmailsHandler, |
554 | 18 | MacaroonHandler, | ||
555 | 18 | PasswordResetTokenHandler, | 19 | PasswordResetTokenHandler, |
556 | 19 | RequestsHandler, | 20 | RequestsHandler, |
557 | 20 | TokensHandler, | 21 | TokensHandler, |
558 | @@ -26,6 +27,7 @@ | |||
559 | 26 | v2emails = ApiResource( | 27 | v2emails = ApiResource( |
560 | 27 | handler=EmailsHandler, authentication=ApiEmailsAuthentication()) | 28 | handler=EmailsHandler, authentication=ApiEmailsAuthentication()) |
561 | 28 | v2login = ApiResource(handler=AccountLoginHandler) | 29 | v2login = ApiResource(handler=AccountLoginHandler) |
562 | 30 | v2macaroon = ApiResource(handler=MacaroonHandler) | ||
563 | 29 | v2password_reset = ApiResource(handler=PasswordResetTokenHandler) | 31 | v2password_reset = ApiResource(handler=PasswordResetTokenHandler) |
564 | 30 | v2registration = ApiResource( | 32 | v2registration = ApiResource( |
565 | 31 | handler=AccountRegistrationHandler, | 33 | handler=AccountRegistrationHandler, |
566 | @@ -41,6 +43,7 @@ | |||
567 | 41 | url(r'^accounts/(?P<openid>\w+)$', v2accounts, name='api-account'), | 43 | url(r'^accounts/(?P<openid>\w+)$', v2accounts, name='api-account'), |
568 | 42 | url(r'^emails/(?P<email>.+)$', v2emails, name='api-email'), | 44 | url(r'^emails/(?P<email>.+)$', v2emails, name='api-email'), |
569 | 43 | url(r'^requests/validate$', v2requests, name='api-requests'), | 45 | url(r'^requests/validate$', v2requests, name='api-requests'), |
570 | 46 | url(r'^tokens/discharge$', v2macaroon, name='api-macaroon-discharge'), | ||
571 | 44 | url(r'^tokens/oauth$', v2login, name='api-login'), | 47 | url(r'^tokens/oauth$', v2login, name='api-login'), |
572 | 45 | url(r'^tokens/oauth/(?P<token>.+)$', v2tokens, name='api-token'), | 48 | url(r'^tokens/oauth/(?P<token>.+)$', v2tokens, name='api-token'), |
573 | 46 | url(r'^tokens/password$', v2password_reset, name='api-password-reset'), | 49 | url(r'^tokens/password$', v2password_reset, name='api-password-reset'), |
574 | 47 | 50 | ||
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 | 1 | # -*- coding: utf-8 -*- | 1 | # -*- coding: utf-8 -*- |
580 | 2 | # | 2 | # |
582 | 3 | # Copyright 2010 Canonical Ltd. This software is licensed under the | 3 | # Copyright 2010-2016 Canonical Ltd. This software is licensed under the |
583 | 4 | # GNU Affero General Public License version 3 (see the file LICENSE). | 4 | # GNU Affero General Public License version 3 (see the file LICENSE). |
584 | 5 | 5 | ||
585 | 6 | from __future__ import unicode_literals | 6 | from __future__ import unicode_literals |
586 | 7 | 7 | ||
587 | 8 | import base64 | ||
588 | 9 | import datetime | ||
589 | 10 | import json | ||
590 | 8 | import logging | 11 | import logging |
591 | 9 | 12 | ||
592 | 10 | from django.conf import settings | 13 | from django.conf import settings |
593 | @@ -21,7 +24,9 @@ | |||
594 | 21 | from django.utils.translation import ugettext_lazy as _ | 24 | from django.utils.translation import ugettext_lazy as _ |
595 | 22 | from django.utils.timezone import now | 25 | from django.utils.timezone import now |
596 | 23 | from oauthlib.oauth1 import RequestValidator, ResourceEndpoint | 26 | from oauthlib.oauth1 import RequestValidator, ResourceEndpoint |
597 | 27 | from pymacaroons import Macaroon | ||
598 | 24 | 28 | ||
599 | 29 | from identityprovider.login import AuthenticationError | ||
600 | 25 | from identityprovider.models import ( | 30 | from identityprovider.models import ( |
601 | 26 | Account, | 31 | Account, |
602 | 27 | AccountPassword, | 32 | AccountPassword, |
603 | @@ -488,3 +493,55 @@ | |||
604 | 488 | response = HttpResponse("Authorization Required", status=401) | 493 | response = HttpResponse("Authorization Required", status=401) |
605 | 489 | response['WWW-Authenticate'] = 'OAuth realm="%s"' % self.realm | 494 | response['WWW-Authenticate'] = 'OAuth realm="%s"' % self.realm |
606 | 490 | return response | 495 | return response |
607 | 496 | |||
608 | 497 | |||
609 | 498 | def build_discharge_macaroon(account, root_macaroon_raw): | ||
610 | 499 | """Build a discharge macaroon from a root one.""" | ||
611 | 500 | service_location = settings.MACAROON_SERVICE_LOCATION | ||
612 | 501 | |||
613 | 502 | try: | ||
614 | 503 | root_macaroon = Macaroon.deserialize(root_macaroon_raw) | ||
615 | 504 | except: | ||
616 | 505 | raise ValidationError("The received Macaroon is corrupt") | ||
617 | 506 | |||
618 | 507 | try: | ||
619 | 508 | # isolate 3rd-party caveat that concerns SSO (hinted by 'location') | ||
620 | 509 | (sso_caveat,) = [c for c in root_macaroon.third_party_caveats() | ||
621 | 510 | if c.location == service_location] | ||
622 | 511 | except: | ||
623 | 512 | # the macaroon doesn't have a location for this service | ||
624 | 513 | raise AuthenticationError("The received macaroon is not for ours") | ||
625 | 514 | |||
626 | 515 | # get the only-for-this-project random key | ||
627 | 516 | original_random_key = settings.CRYPTO_SSO_PRIVKEY.decrypt( | ||
628 | 517 | base64.b64decode(sso_caveat.caveat_id)) | ||
629 | 518 | |||
630 | 519 | # create a discharge macaroon with same location, key and | ||
631 | 520 | # identifier than it's original 3rd-party caveat (so they can | ||
632 | 521 | # be matched and verified) | ||
633 | 522 | d = Macaroon( | ||
634 | 523 | location=service_location, | ||
635 | 524 | key=original_random_key, | ||
636 | 525 | identifier=sso_caveat.caveat_id, | ||
637 | 526 | ) | ||
638 | 527 | |||
639 | 528 | # add the account info | ||
640 | 529 | account_info = base64.b64encode(json.dumps({ | ||
641 | 530 | 'openid': account.openid_identifier, | ||
642 | 531 | 'email': account.preferredemail.email, | ||
643 | 532 | 'displayname': account.displayname, | ||
644 | 533 | }).encode("utf8")) | ||
645 | 534 | d.add_first_party_caveat(service_location + '|account|' + account_info) | ||
646 | 535 | |||
647 | 536 | # add timestamp values | ||
648 | 537 | now_string = now().strftime('%Y-%m-%dT%H:%M:%S.%f') | ||
649 | 538 | d.add_first_party_caveat(service_location + '|valid_since|' + now_string) | ||
650 | 539 | d.add_first_party_caveat(service_location + '|last_auth|' + now_string) | ||
651 | 540 | expire = now() + datetime.timedelta(seconds=settings.MACAROON_TTL) | ||
652 | 541 | d.add_first_party_caveat( | ||
653 | 542 | service_location + '|expires|' + | ||
654 | 543 | expire.strftime('%Y-%m-%dT%H:%M:%S.%f')) | ||
655 | 544 | |||
656 | 545 | # return the properly prepared discharge macaroon | ||
657 | 546 | discharge = root_macaroon.prepare_for_request(d) | ||
658 | 547 | return discharge | ||
659 | 491 | 548 | ||
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 | 1 | # -*- coding: utf-8 -*- | ||
665 | 2 | from __future__ import unicode_literals | ||
666 | 3 | |||
667 | 4 | from django.db import migrations, models | ||
668 | 5 | |||
669 | 6 | |||
670 | 7 | class Migration(migrations.Migration): | ||
671 | 8 | |||
672 | 9 | dependencies = [ | ||
673 | 10 | ('identityprovider', '0006_auto_20151019_1515'), | ||
674 | 11 | ] | ||
675 | 12 | |||
676 | 13 | operations = [ | ||
677 | 14 | migrations.AlterField( | ||
678 | 15 | model_name='authlog', | ||
679 | 16 | name='log_type', | ||
680 | 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')]), | ||
681 | 18 | ), | ||
682 | 19 | ] | ||
683 | 0 | 20 | ||
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 | 114 | API_FAIL = 4 | 114 | API_FAIL = 4 |
689 | 115 | API_NEW = 5 # A new oauth token created for the account | 115 | API_NEW = 5 # A new oauth token created for the account |
690 | 116 | API_EXISTING = 6 # Existing oauth token retrieved | 116 | API_EXISTING = 6 # Existing oauth token retrieved |
691 | 117 | API_MACAROON_DISCHARGE_NEW = 7 # First time a macaroon was discharged | ||
692 | 117 | 118 | ||
693 | 118 | _verbose = { | 119 | _verbose = { |
694 | 119 | OPENID_FAIL: _("Failed web login"), | 120 | OPENID_FAIL: _("Failed web login"), |
695 | @@ -121,7 +122,8 @@ | |||
696 | 121 | OPENID_EXISTING: _("Existing web login"), | 122 | OPENID_EXISTING: _("Existing web login"), |
697 | 122 | API_FAIL: _("Failed API login"), | 123 | API_FAIL: _("Failed API login"), |
698 | 123 | API_NEW: _("New API login"), | 124 | API_NEW: _("New API login"), |
700 | 124 | API_EXISTING: _("Existing API login") | 125 | API_EXISTING: _("Existing API login"), |
701 | 126 | API_MACAROON_DISCHARGE_NEW: _("First time discharge for the macaroon"), | ||
702 | 125 | } | 127 | } |
703 | 126 | 128 | ||
704 | 127 | @classmethod | 129 | @classmethod |
705 | 128 | 130 | ||
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 | 1 | # encoding: utf-8 | 1 | # encoding: utf-8 |
712 | 2 | # Copyright 2010 Canonical Ltd. This software is licensed under the | 2 | # Copyright 2010-2016 Canonical Ltd. This software is licensed under the |
713 | 3 | # GNU Affero General Public License version 3 (see the file LICENSE). | 3 | # GNU Affero General Public License version 3 (see the file LICENSE). |
714 | 4 | 4 | ||
715 | 5 | import base64 | ||
716 | 6 | import binascii | ||
717 | 7 | import json | ||
718 | 8 | import os | ||
719 | 9 | |||
720 | 5 | from contextlib import contextmanager | 10 | from contextlib import contextmanager |
721 | 11 | from datetime import datetime, timedelta | ||
722 | 6 | from uuid import uuid4 | 12 | from uuid import uuid4 |
723 | 7 | 13 | ||
724 | 14 | from Crypto import Random | ||
725 | 15 | from Crypto.PublicKey import RSA | ||
726 | 8 | from django.conf import settings | 16 | from django.conf import settings |
727 | 9 | from django.contrib.auth.hashers import get_hasher | 17 | from django.contrib.auth.hashers import get_hasher |
728 | 10 | from django.contrib.auth.models import ( | 18 | from django.contrib.auth.models import ( |
729 | @@ -12,18 +20,22 @@ | |||
730 | 12 | is_password_usable, | 20 | is_password_usable, |
731 | 13 | make_password, | 21 | make_password, |
732 | 14 | ) | 22 | ) |
733 | 23 | from django.core.exceptions import ValidationError | ||
734 | 15 | from django.db import connection | 24 | from django.db import connection |
735 | 16 | from django.test.utils import override_settings | 25 | from django.test.utils import override_settings |
736 | 17 | from django.utils.timezone import now | 26 | from django.utils.timezone import now |
737 | 18 | from mock import call, Mock, patch | 27 | from mock import call, Mock, patch |
738 | 28 | from pymacaroons import Macaroon, Verifier | ||
739 | 19 | 29 | ||
740 | 20 | from identityprovider.auth import ( | 30 | from identityprovider.auth import ( |
741 | 21 | LaunchpadBackend, | 31 | LaunchpadBackend, |
742 | 22 | SSOOAuthAuthentication, | 32 | SSOOAuthAuthentication, |
743 | 23 | SSORequestValidator, | 33 | SSORequestValidator, |
744 | 24 | basic_authenticate, | 34 | basic_authenticate, |
745 | 35 | build_discharge_macaroon, | ||
746 | 25 | validate_oauth_signature, | 36 | validate_oauth_signature, |
747 | 26 | ) | 37 | ) |
748 | 38 | from identityprovider.login import AuthenticationError | ||
749 | 27 | from identityprovider.models import ( | 39 | from identityprovider.models import ( |
750 | 28 | Account, | 40 | Account, |
751 | 29 | AccountPassword, | 41 | AccountPassword, |
752 | @@ -940,3 +952,95 @@ | |||
753 | 940 | expected_detail="(direct password comparison)", | 952 | expected_detail="(direct password comparison)", |
754 | 941 | hashed_password=make_password(DEFAULT_USER_PASSWORD), | 953 | hashed_password=make_password(DEFAULT_USER_PASSWORD), |
755 | 942 | timer=self.dummy_timer) | 954 | timer=self.dummy_timer) |
756 | 955 | |||
757 | 956 | |||
758 | 957 | class BuildMacaroonDischargeTestCase(SSOBaseTestCase): | ||
759 | 958 | |||
760 | 959 | def build_macaroon(self, service_location=None): | ||
761 | 960 | if service_location is None: | ||
762 | 961 | service_location = settings.MACAROON_SERVICE_LOCATION | ||
763 | 962 | |||
764 | 963 | # pair of keys to encrypt/decrypt | ||
765 | 964 | Random.atfork() | ||
766 | 965 | test_rsa_priv_key = RSA.generate(1024) | ||
767 | 966 | test_rsa_pub_key = test_rsa_priv_key.publickey() | ||
768 | 967 | p = self.settings(CRYPTO_SSO_PRIVKEY=test_rsa_priv_key) | ||
769 | 968 | p.enable() | ||
770 | 969 | self.addCleanup(p.disable) | ||
771 | 970 | |||
772 | 971 | # create a Macaron with the proper third party caveat | ||
773 | 972 | macaroon_random_key = binascii.hexlify(os.urandom(32)) | ||
774 | 973 | root_macaroon = Macaroon( | ||
775 | 974 | location='The store ;)', | ||
776 | 975 | key=macaroon_random_key, | ||
777 | 976 | identifier='A test macaroon', | ||
778 | 977 | ) | ||
779 | 978 | random_key = binascii.hexlify(os.urandom(32)) | ||
780 | 979 | random_key_encrypted = base64.b64encode( | ||
781 | 980 | test_rsa_pub_key.encrypt(random_key, 32)[0]) | ||
782 | 981 | root_macaroon.add_third_party_caveat( | ||
783 | 982 | service_location, random_key, random_key_encrypted) | ||
784 | 983 | return root_macaroon, macaroon_random_key | ||
785 | 984 | |||
786 | 985 | def test_root_macaroon_corrupt(self): | ||
787 | 986 | self.assertRaises(ValidationError, build_discharge_macaroon, | ||
788 | 987 | "fake account", "I'm a seriously corrupted macaroon") | ||
789 | 988 | |||
790 | 989 | def test_root_macaroon_not_for_sso(self): | ||
791 | 990 | macaroon, _ = self.build_macaroon(service_location="other service") | ||
792 | 991 | self.assertRaises(AuthenticationError, build_discharge_macaroon, | ||
793 | 992 | "fake account", macaroon.serialize()) | ||
794 | 993 | |||
795 | 994 | def test_proper_discharging(self): | ||
796 | 995 | # build the input and call | ||
797 | 996 | root_macaroon, random_key_used = self.build_macaroon() | ||
798 | 997 | real_account = self.factory.make_account() | ||
799 | 998 | before = now() | ||
800 | 999 | discharge_macaroon = build_discharge_macaroon( | ||
801 | 1000 | real_account, root_macaroon.serialize()) | ||
802 | 1001 | after = now() | ||
803 | 1002 | |||
804 | 1003 | # test | ||
805 | 1004 | def checker(caveat): | ||
806 | 1005 | """Assure all caveats inside the discharged macaroon are ok.""" | ||
807 | 1006 | source, key, value = caveat.split("|", 2) | ||
808 | 1007 | if source != settings.MACAROON_SERVICE_LOCATION: | ||
809 | 1008 | # just checking the discharge one, not the root | ||
810 | 1009 | return True | ||
811 | 1010 | |||
812 | 1011 | if key == 'valid_since': | ||
813 | 1012 | valid_since = datetime.strptime(value, '%Y-%m-%dT%H:%M:%S.%f') | ||
814 | 1013 | self.assertGreater(valid_since, before) | ||
815 | 1014 | self.assertGreater(after, valid_since) | ||
816 | 1015 | return True | ||
817 | 1016 | |||
818 | 1017 | if key == 'last_auth': | ||
819 | 1018 | last_auth = datetime.strptime(value, '%Y-%m-%dT%H:%M:%S.%f') | ||
820 | 1019 | self.assertGreater(last_auth, before) | ||
821 | 1020 | self.assertGreater(after, last_auth) | ||
822 | 1021 | return True | ||
823 | 1022 | |||
824 | 1023 | if key == 'account': | ||
825 | 1024 | acc = json.loads(base64.b64decode(value).decode("utf8")) | ||
826 | 1025 | self.assertEqual(acc['openid'], real_account.openid_identifier) | ||
827 | 1026 | self.assertEqual(acc['email'], | ||
828 | 1027 | real_account.preferredemail.email) | ||
829 | 1028 | self.assertEqual(acc['displayname'], real_account.displayname) | ||
830 | 1029 | return True | ||
831 | 1030 | |||
832 | 1031 | if key == 'expires': | ||
833 | 1032 | expires = datetime.strptime(value, '%Y-%m-%dT%H:%M:%S.%f') | ||
834 | 1033 | before_plus_ttl = before + timedelta( | ||
835 | 1034 | seconds=settings.MACAROON_TTL) | ||
836 | 1035 | after_plus_ttl = after + timedelta( | ||
837 | 1036 | seconds=settings.MACAROON_TTL) | ||
838 | 1037 | self.assertGreater(expires, before_plus_ttl) | ||
839 | 1038 | self.assertGreater(after_plus_ttl, expires) | ||
840 | 1039 | return True | ||
841 | 1040 | |||
842 | 1041 | # we're not validating an SSO from the discharged macaroon, fail! | ||
843 | 1042 | return False | ||
844 | 1043 | |||
845 | 1044 | v = Verifier() | ||
846 | 1045 | v.satisfy_general(checker) | ||
847 | 1046 | v.verify(root_macaroon, random_key_used, [discharge_macaroon]) | ||
848 | 943 | 1047 | ||
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 | 15 | vm.update = True | 15 | vm.update = True |
854 | 16 | vm.packages = {sso.dependencies}, python-uci-vms, python-novaclient | 16 | vm.packages = {sso.dependencies}, python-uci-vms, python-novaclient |
855 | 17 | vm.bind_home = True | 17 | vm.bind_home = True |
857 | 18 | vm.apt_sources = deb https://{ppa.u1_hackers.user_pass}@private-ppa.launchpad.net/ubuntuone/hackers/ubuntu {vm.release} main|4BD0ECAE | 18 | 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 | 19 | 19 | ||
859 | 20 | # vms named sso-worker-0n are built from here | 20 | # vms named sso-worker-0n are built from here |
860 | 21 | [sso-worker] | 21 | [sso-worker] |
861 | @@ -49,5 +49,5 @@ | |||
862 | 49 | # /!\ 'ppa.u1_hackers.user_pass = <USER>:<PASSWORD>' should be defined in | 49 | # /!\ 'ppa.u1_hackers.user_pass = <USER>:<PASSWORD>' should be defined in |
863 | 50 | # ~/uci-vms.conf, see https://launchpad.net/~/+archivesubscriptions to find | 50 | # ~/uci-vms.conf, see https://launchpad.net/~/+archivesubscriptions to find |
864 | 51 | # your password | 51 | # your password |
866 | 52 | 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 | 52 | 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 | 53 | vm.packages = {sso.dependencies}, python3-uci-vms, lxc, lxc-templates, debootstrap | 53 | vm.packages = {sso.dependencies}, python3-uci-vms, lxc, lxc-templates, debootstrap |
Approve it as formal review and everything happened in the other MP.