Merge lp:~ricardokirkner/rnr-server/submit-review-with-macaroons into lp:rnr-server

Proposed by Ricardo Kirkner
Status: Merged
Approved by: Ricardo Kirkner
Approved revision: 327
Merged at revision: 317
Proposed branch: lp:~ricardokirkner/rnr-server/submit-review-with-macaroons
Merge into: lp:rnr-server
Diff against target: 982 lines (+577/-37)
13 files modified
requirements-devel.txt (+1/-0)
requirements.txt (+1/-0)
src/clickreviews/forms.py (+2/-2)
src/core/api/auth/__init__.py (+2/-0)
src/core/api/auth/macaroon.py (+82/-0)
src/core/api/auth/sso.py (+2/-2)
src/core/tests/doubles.py (+119/-0)
src/core/tests/helpers.py (+42/-0)
src/core/tests/test_api_auth.py (+121/-2)
src/core/tests/test_utilities.py (+123/-15)
src/core/utilities.py (+40/-6)
src/reviewsapp/api/urls.py (+4/-3)
src/reviewsapp/tests/test_handlers.py (+38/-7)
To merge this branch: bzr merge lp:~ricardokirkner/rnr-server/submit-review-with-macaroons
Reviewer Review Type Date Requested Status
Fabián Ezequiel Gallina (community) Approve
Review via email: mp+294817@code.launchpad.net

Commit message

support submitting reviews using macaroon based authentication

To post a comment you must log in.
Revision history for this message
Fabián Ezequiel Gallina (fgallina) wrote :

added few comments, otherwise is looking good.

322. By Ricardo Kirkner

improvements per review

Revision history for this message
Ricardo Kirkner (ricardokirkner) :
Revision history for this message
Fabián Ezequiel Gallina (fgallina) wrote :

added question and we are good to go.

323. By Ricardo Kirkner

switch api from GET to POST

324. By Ricardo Kirkner

remove unnecessary decoration

325. By Ricardo Kirkner

unify code into update_or_create_user_from_sso

326. By Ricardo Kirkner

updated code based on changes on the verify_acl api

Revision history for this message
Fabián Ezequiel Gallina (fgallina) wrote :

Looking good, added minor comments.

327. By Ricardo Kirkner

minor changes per review

Revision history for this message
Ricardo Kirkner (ricardokirkner) :
Revision history for this message
Fabián Ezequiel Gallina (fgallina) wrote :

LGTM

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'requirements-devel.txt'
--- requirements-devel.txt 2015-10-26 15:12:50 +0000
+++ requirements-devel.txt 2016-05-17 14:32:36 +0000
@@ -10,5 +10,6 @@
10paste==1.7.5.110paste==1.7.5.1
11pep8==1.5.711pep8==1.5.7
12piston-mini-client12piston-mini-client
13responses==0.5.1
13testtools==0.9.3414testtools==0.9.34
14wsgi-intercept==0.5.115wsgi-intercept==0.5.1
1516
=== modified file 'requirements.txt'
--- requirements.txt 2015-10-26 15:11:49 +0000
+++ requirements.txt 2016-05-17 14:32:36 +0000
@@ -12,6 +12,7 @@
12oops-dictconfig==0.0.412oops-dictconfig==0.0.4
13oops-wsgi==0.0.11 # bzr+ssh://bazaar.launchpad.net/~ubuntuone-pqm-team/python-oops-wsgi/stable@3913oops-wsgi==0.0.11 # bzr+ssh://bazaar.launchpad.net/~ubuntuone-pqm-team/python-oops-wsgi/stable@39
1414
15requests==2.6.0
15requests-oauthlib==0.5.016requests-oauthlib==0.5.0
16south==1.0.217south==1.0.2
17txstatsd==1.0.018txstatsd==1.0.0
1819
=== modified file 'src/clickreviews/forms.py'
--- src/clickreviews/forms.py 2015-04-17 12:39:41 +0000
+++ src/clickreviews/forms.py 2016-05-17 14:32:36 +0000
@@ -14,7 +14,7 @@
14from clickreviews.utilities import verify_click_package_published14from clickreviews.utilities import verify_click_package_published
15from core.forms import JSONErrorMixin15from core.forms import JSONErrorMixin
16from core.models import BaseModeration16from core.models import BaseModeration
17from core.utilities import update_or_create_user_from_oauth17from core.utilities import update_or_create_user_from_sso
1818
1919
20class ClickPackageReviewForm(forms.ModelForm, JSONErrorMixin):20class ClickPackageReviewForm(forms.ModelForm, JSONErrorMixin):
@@ -239,7 +239,7 @@
239239
240 def clean_moderator(self):240 def clean_moderator(self):
241 openid = self.cleaned_data['moderator']241 openid = self.cleaned_data['moderator']
242 user, __ = update_or_create_user_from_oauth(openid)242 user, __ = update_or_create_user_from_sso(openid)
243 if user is None:243 if user is None:
244 self.add_error(244 self.add_error(
245 'moderator', _('User given as moderator not found.'))245 'moderator', _('User given as moderator not found.'))
246246
=== modified file 'src/core/api/auth/__init__.py'
--- src/core/api/auth/__init__.py 2014-09-24 14:57:52 +0000
+++ src/core/api/auth/__init__.py 2016-05-17 14:32:36 +0000
@@ -1,8 +1,10 @@
1from core.api.auth.delegated_sso import DelegatedSSOAuthentication1from core.api.auth.delegated_sso import DelegatedSSOAuthentication
2from core.api.auth.macaroon import MacaroonsAuthentication
2from core.api.auth.sso import SSOOAuthAuthentication3from core.api.auth.sso import SSOOAuthAuthentication
34
45
5__all__ = [6__all__ = [
6 'DelegatedSSOAuthentication',7 'DelegatedSSOAuthentication',
8 'MacaroonsAuthentication',
7 'SSOOAuthAuthentication',9 'SSOOAuthAuthentication',
8]10]
911
=== added file 'src/core/api/auth/macaroon.py'
--- src/core/api/auth/macaroon.py 1970-01-01 00:00:00 +0000
+++ src/core/api/auth/macaroon.py 2016-05-17 14:32:36 +0000
@@ -0,0 +1,82 @@
1# This file is part of Software Center Ratings and Reviews
2# Copyright (C) 2016 Canonical Ltd.
3#
4# This program is free software: you can redistribute it and/or modify
5# it under the terms of the GNU Affero General Public License as
6# published by the Free Software Foundation, either version 3 of the
7# License, or (at your option) any later version.
8#
9# This program is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU Affero General Public License for more details.
13#
14# You should have received a copy of the GNU Affero General Public License
15# along with this program. If not, see <http://www.gnu.org/licenses/>.
16
17"""Macaroon authenticator for Piston."""
18
19from __future__ import absolute_import
20
21import logging
22
23from django.http import HttpResponse
24from piston.authentication import OAuthAuthentication
25
26from core.utilities import (
27 update_or_create_user_from_sso,
28 web_services,
29)
30
31
32logger = logging.getLogger(__name__)
33
34
35def has_macaroon_bits(authorization):
36 """Return whether there are Macaroon bits in authorization.
37
38 Macaroon bits are detected by ensuring the authorization starts with
39 'Macaroon'.
40
41 """
42 has_bits = (
43 authorization and authorization.split()[0].lower() == 'macaroon')
44 return has_bits
45
46
47class MacaroonsAuthentication(OAuthAuthentication):
48 """ This class is a Piston Authentication class.
49
50 See http://bitbucket.org/jespern/django-piston/wiki/Documentation for
51 more info.
52 """
53 def __init__(self, realm='API'):
54 self.realm = realm
55
56 def challenge(self):
57 """ We define our own challenge so that we can provide our own realm
58 and return a simple error message instead of rendering a template.
59 """
60 resp = HttpResponse("Authorization Required")
61 resp['WWW-Authenticate'] = 'Macaroon realm="%s"' % self.realm
62 resp.status_code = 401
63 return resp
64
65 def is_authenticated(self, request):
66 authorization = request.META.get('HTTP_AUTHORIZATION', None)
67 if not has_macaroon_bits(authorization):
68 # skip network request as macaroon headers are missing
69 return False
70 data = web_services.verify_acl(request)
71 account = data and data.get('account', {}) or {}
72 verified = account.get('verified', False)
73 if not verified:
74 return False
75
76 # get or create user account
77 user, _ = update_or_create_user_from_sso(
78 account.get('openid'), account=account)
79
80 # inject user into request
81 request.user = user
82 return True
083
=== modified file 'src/core/api/auth/sso.py'
--- src/core/api/auth/sso.py 2015-10-09 18:54:55 +0000
+++ src/core/api/auth/sso.py 2016-05-17 14:32:36 +0000
@@ -25,7 +25,7 @@
2525
26from core.utilities import (26from core.utilities import (
27 web_services,27 web_services,
28 update_or_create_user_from_oauth,28 update_or_create_user_from_sso,
29)29)
3030
3131
@@ -93,7 +93,7 @@
9393
94 openid = oauth_request.get_parameter('oauth_consumer_key')94 openid = oauth_request.get_parameter('oauth_consumer_key')
9595
96 user, _ = update_or_create_user_from_oauth(openid)96 user, _ = update_or_create_user_from_sso(openid)
97 if user is not None:97 if user is not None:
98 request.user = user98 request.user = user
99 else:99 else:
100100
=== added file 'src/core/tests/doubles.py'
--- src/core/tests/doubles.py 1970-01-01 00:00:00 +0000
+++ src/core/tests/doubles.py 2016-05-17 14:32:36 +0000
@@ -0,0 +1,119 @@
1from __future__ import absolute_import, unicode_literals
2
3import json
4
5import responses
6from django.conf import settings
7from requests import PreparedRequest
8
9from core.utilities import WebServices
10
11
12# XXX: RequestsDouble and RequestsDoubleTestCaseMixin come from
13# appstore-common, this copypasta (with modifications) is an attempt
14# to generalize it a bit and later on move the code into its own
15# module and reuse it across projects.
16class RequestsDouble(object):
17
18 def start(self):
19 self.reset()
20 responses.start()
21
22 def stop(self):
23 # Stopping does not triggers a reset and it should because
24 # responses dirty state can become a problem for other tests.
25 try:
26 self.reset()
27 responses.stop()
28 except RuntimeError:
29 raise RuntimeError(
30 'Calling stop on a RequestDouble that is not started.'
31 'This may be because you are instantiating your own instead '
32 'of reusing the RequestDouble from your TestCase')
33
34 def reset(self):
35 responses.reset()
36
37 def reset_calls(self):
38 responses.calls.reset()
39
40 def make_body(self, data=None, files=None):
41 req = PreparedRequest()
42 # need to define headers before body
43 req.prepare_headers({})
44 req.prepare_body(data=data, files=files)
45 return req.body
46
47 def add_response(self, url, status_code=200, body='',
48 match_querystring=False, method='GET',
49 callback=None, reset=False):
50 if reset:
51 self.reset()
52 if callback:
53 responses.add_callback(method, url, callback=callback,
54 match_querystring=match_querystring)
55 else:
56 responses.add(method, url, match_querystring=match_querystring,
57 status=status_code, body=body)
58
59 def _call_has_headers(self, call, headers):
60 has_headers = True
61
62 if headers is not None:
63 # make sure all given headers were included in call
64 for key, value in headers.items():
65 if call.request.headers.get(key) != value:
66 has_headers = False
67 break
68
69 return has_headers
70
71 def find_call(self, url, headers=None, body=None, method=None):
72 """Find calls for url optionally matching headers, body and method.
73
74 The given headers can be a subset of the actual call headers,
75 body must be an exact match.
76
77 """
78 for call in responses.calls:
79 test_body = call.request.body if body is None else body
80 if (call.request.url == url
81 and self._call_has_headers(call, headers)
82 and test_body == call.request.body):
83 if method is not None and call.request.method != method:
84 continue
85 return (url, call.request.headers, call.request.body)
86
87 def find_calls(self, url, method=None):
88 calls = (c for c in responses.calls if c.request.url == url)
89 if method:
90 calls = (c for c in calls if c.request.method == method)
91 return [(url, c.request.headers, c.request.body) for c in calls]
92
93
94class SCADouble(WebServices):
95
96 def __init__(self, requests_double, *args, **kwargs):
97 WebServices.__init__(self, *args, **kwargs)
98 self.requests_double = requests_double
99
100 self.verify_acl_url = (
101 settings.SCA_HOST_URL.strip('/') + '/dev/api/acl/verify/')
102
103 def set_verify_acl_response(self, **kwargs):
104 body = {
105 'allowed': True,
106 'refresh_required': False,
107 'account': {
108 'openid': 'oid1234',
109 'email': 'user@example.com',
110 'displayname': 'user1234',
111 'verified': True,
112 },
113 'last_auth': '20160515T12:00:00.000',
114 'permissions': ['package_access'],
115 }
116 body.update(**kwargs)
117 self.requests_double.add_response(
118 self.verify_acl_url, method='POST',
119 body=json.dumps(body))
0120
=== modified file 'src/core/tests/helpers.py'
--- src/core/tests/helpers.py 2015-04-22 13:24:20 +0000
+++ src/core/tests/helpers.py 2016-05-17 14:32:36 +0000
@@ -5,6 +5,7 @@
5from mock import patch5from mock import patch
66
7from core.utilities import web_services7from core.utilities import web_services
8from core.tests.doubles import RequestsDouble
89
910
10class SSOTestCaseMixin(object):11class SSOTestCaseMixin(object):
@@ -83,3 +84,44 @@
83 message += "\n\n\nSecond queries:\n"84 message += "\n\n\nSecond queries:\n"
84 message += format_queries(second.captured_queries)85 message += format_queries(second.captured_queries)
85 raise AssertionError(message)86 raise AssertionError(message)
87
88
89# Why a mixin? Because we want to be able to plug this functionality
90# to any of the variants of TestCases the user may have without
91# relying on hierarchy (e.g. `unittest.TestCase`,
92# `django.test.TestCase`). An effect of this is that `patch_requests`
93# must be explicitly called to activate requests double functionality,
94# which may be a good thing if you want fine-grained control.
95class RequestsDoubleTestCaseMixin(object):
96 """Mixin to add requests double functionality.
97
98 To enable it, `patch_requests` must be called prior your tests are
99 executed, so generally this is a good thing to be placed in the
100 setUp method of your `TestCase` classes unless you wish more
101 fine-grained control.
102 """
103
104 def patch_requests(self):
105 """Enable requests double.
106
107 Takes care of cleaning up itself after a test is run.
108 """
109 self.requests_double = RequestsDouble()
110 self.requests_double.start()
111 self.addCleanup(self.requests_double.stop)
112
113 def patch_choose_boundary(self, boundary='theboundary'):
114 return self.patch(
115 'requests.packages.urllib3.filepost.choose_boundary',
116 return_value=boundary.encode('utf-8'))
117
118 def assert_call(self, url, headers=None, body=None, method=None):
119 """Assert call to url and headers was executed."""
120 call = self.requests_double.find_call(
121 url, headers, body, method=method)
122 if call is None:
123 self.fail(
124 "No matching call found. url=%s headers=%s body=%s "
125 "method=%s" % (url, headers, body, method))
126
127
86128
=== modified file 'src/core/tests/test_api_auth.py'
--- src/core/tests/test_api_auth.py 2015-10-09 19:31:57 +0000
+++ src/core/tests/test_api_auth.py 2016-05-17 14:32:36 +0000
@@ -37,9 +37,11 @@
37 SSOOAuthAuthentication,37 SSOOAuthAuthentication,
38 delegated_sso,38 delegated_sso,
39)39)
40from core.api.auth.macaroon import MacaroonsAuthentication, has_macaroon_bits
40from core.api.auth.sso import has_oauth_bits41from core.api.auth.sso import has_oauth_bits
42from core.tests.doubles import SCADouble
41from core.tests.factory import TestCaseWithFactory43from core.tests.factory import TestCaseWithFactory
42from core.tests.helpers import SSOTestCaseMixin44from core.tests.helpers import RequestsDoubleTestCaseMixin, SSOTestCaseMixin
43from core.utilities import full_claimed_id45from core.utilities import full_claimed_id
4446
4547
@@ -53,6 +55,7 @@
53 self.method = method55 self.method = method
54 self.environ = self.META56 self.environ = self.META
55 self.uri = uri57 self.uri = uri
58 self.user = None
5659
57 def build_absolute_uri(self):60 def build_absolute_uri(self):
58 return self.uri61 return self.uri
@@ -134,6 +137,17 @@
134 self.assertFalse(result, self.no_oauth % not_allowed)137 self.assertFalse(result, self.no_oauth % not_allowed)
135138
136139
140class HasMacaroonBitsTestCase(TestCase):
141
142 def test_has_macaroon_bits(self):
143 for authorization in ('Macaroon zaraza', 'macaroon zaraza'):
144 self.assertTrue(has_macaroon_bits(authorization))
145
146 def test_has_not_macaroon_bits(self):
147 for authorization in ('OAuth zaraza', 'Auth macaroon zaraza'):
148 self.assertFalse(has_macaroon_bits(authorization))
149
150
137class SSOOAuthAuthenticationTestCase(BaseAuthenticationTestCase):151class SSOOAuthAuthenticationTestCase(BaseAuthenticationTestCase):
138152
139 def _get_auth(self):153 def _get_auth(self):
@@ -221,7 +235,7 @@
221 self.user.delete()235 self.user.delete()
222 self.factory.makeUser()236 self.factory.makeUser()
223237
224 get_method = 'core.api.auth.sso.update_or_create_user_from_oauth'238 get_method = 'core.api.auth.sso.update_or_create_user_from_sso'
225 with patch(get_method) as mock_get_user:239 with patch(get_method) as mock_get_user:
226 mock_get_user.return_value = (None, False)240 mock_get_user.return_value = (None, False)
227 response = self.get_resource(request)241 response = self.get_resource(request)
@@ -669,3 +683,108 @@
669 request = self.make_request('POST', self.signature, body='wrong')683 request = self.make_request('POST', self.signature, body='wrong')
670 response = self.post_resource(request)684 response = self.post_resource(request)
671 self.assertEqual(httplib.UNAUTHORIZED, response.status_code)685 self.assertEqual(httplib.UNAUTHORIZED, response.status_code)
686
687
688class MacaroonsAuthenticationTestCase(BaseAuthenticationTestCase,
689 RequestsDoubleTestCaseMixin):
690
691 def setUp(self):
692 super(MacaroonsAuthenticationTestCase, self).setUp()
693 self.patch_requests()
694
695 self.sca_double = SCADouble(self.requests_double)
696 p = patch('core.utilities.web_services', new=self.sca_double)
697 p.start()
698 self.addCleanup(p.stop)
699
700 def _get_auth(self):
701 return MacaroonsAuthentication(realm="Ratings and Reviews")
702
703 def make_request(self):
704 request = MockRequest(method='POST')
705 authorization = 'Macaroon root="%s", discharge="%s"' % (
706 'root-macaroon-data', 'discharge-macaroon-data')
707 request.META['HTTP_AUTHORIZATION'] = authorization
708 return request
709
710 def test_no_auth_fails(self):
711 request = MockRequest()
712 assert not request.META.get('HTTP_AUTHORIZATION')
713
714 response = self.post_resource(request)
715
716 self.assertEqual(httplib.UNAUTHORIZED, response.status_code)
717 self.assertEqual('Macaroon realm="Ratings and Reviews"',
718 response['WWW-Authenticate'])
719
720 def test_no_macaroon_authorization_header(self):
721 request = self.make_request()
722 request.META['HTTP_AUTHORIZATION'] = 'Basic realm="foo"'
723
724 response = self.post_resource(request)
725 self.assertEqual(httplib.UNAUTHORIZED, response.status_code)
726
727 def test_success(self):
728 self.sca_double.set_verify_acl_response()
729 request = self.make_request()
730
731 response = self.post_resource(request)
732 self.assertEqual(httplib.CREATED, response.status_code)
733
734 def test_failure(self):
735 self.requests_double.add_response(
736 self.sca_double.verify_acl_url, method='POST', status_code=400)
737 request = self.make_request()
738
739 response = self.post_resource(request)
740 self.assertEqual(httplib.UNAUTHORIZED, response.status_code)
741
742 def test_successful_authentication_injects_user(self):
743 self.sca_double.set_verify_acl_response(
744 openid='oid1234', displayname='user1234',
745 email='user@example.com', is_verified=True)
746 request = self.make_request()
747
748 response = self.auth.is_authenticated(request)
749 self.assertTrue(response)
750
751 # a new user was created
752 self.assertEqual(User.objects.count(), 2)
753 user = request.user
754 self.assertIsInstance(user, User)
755 self.assertEqual(user.username, 'oid1234')
756 self.assertEqual(user.last_name, 'user1234')
757 self.assertEqual(user.email, 'user@example.com')
758 openid = user.useropenid_set.get().claimed_id.split('/')[-1]
759 self.assertEqual(openid, 'oid1234')
760
761 def test_do_not_create_user_if_exists(self):
762 account = {
763 'openid': self.openid,
764 'displayname': 'user1234',
765 'email': 'user@example.com',
766 'verified': True,
767 }
768 self.sca_double.set_verify_acl_response(account=account)
769 request = self.make_request()
770
771 response = self.auth.is_authenticated(request)
772 self.assertTrue(response)
773
774 self.assertEqual(request.user, self.user)
775 self.assertEqual(User.objects.count(), 1)
776
777 def test_do_not_create_user_if_user_not_verified(self):
778 account = {
779 'openid': self.openid,
780 'displayname': 'user1234',
781 'email': 'user@example.com',
782 'verified': False,
783 }
784 self.sca_double.set_verify_acl_response(account=account)
785 request = self.make_request()
786
787 response = self.auth.is_authenticated(request)
788 self.assertFalse(response)
789
790 self.assertIsNone(request.user)
672791
=== modified file 'src/core/tests/test_utilities.py'
--- src/core/tests/test_utilities.py 2015-04-22 19:38:37 +0000
+++ src/core/tests/test_utilities.py 2016-05-17 14:32:36 +0000
@@ -3,16 +3,20 @@
3from django.conf import settings3from django.conf import settings
4from django.core.cache import cache4from django.core.cache import cache
5from django.test import TestCase5from django.test import TestCase
6from django.test.client import RequestFactory
6from django.test.utils import override_settings7from django.test.utils import override_settings
8from django.utils.timezone import now
7from launchpadlib.errors import HTTPError9from launchpadlib.errors import HTTPError
8from mock import patch, Mock10from mock import patch, Mock
9from piston_mini_client import PistonResponseObject11from piston_mini_client import PistonResponseObject
1012
13from core.tests.doubles import SCADouble
14from core.tests.helpers import RequestsDoubleTestCaseMixin
11from core.tests.factory import TestCaseWithFactory15from core.tests.factory import TestCaseWithFactory
12from core.utilities import (16from core.utilities import (
13 WebServices,17 WebServices,
14 full_claimed_id,18 full_claimed_id,
15 update_or_create_user_from_oauth,19 update_or_create_user_from_sso,
16 web_services,20 web_services,
17)21)
1822
@@ -305,10 +309,10 @@
305 self.assertTrue(claimed_id.endswith(consumer_key))309 self.assertTrue(claimed_id.endswith(consumer_key))
306310
307311
308class UpdateOrCreateUserFromOauthTestCase(TestCaseWithFactory):312class UpdateOrCreateUserFromSSOTestCase(TestCaseWithFactory):
309313
310 def setUp(self):314 def setUp(self):
311 super(UpdateOrCreateUserFromOauthTestCase, self).setUp()315 super(UpdateOrCreateUserFromSSOTestCase, self).setUp()
312 self.user = self.factory.makeUser()316 self.user = self.factory.makeUser()
313 p = patch('core.utilities.web_services')317 p = patch('core.utilities.web_services')
314 self.mock_web_services = p.start()318 self.mock_web_services = p.start()
@@ -338,28 +342,28 @@
338342
339 def test_missing_account_data_and_missing_user(self):343 def test_missing_account_data_and_missing_user(self):
340 self.mock_get_data.return_value = None344 self.mock_get_data.return_value = None
341 user, created = update_or_create_user_from_oauth('inexistent')345 user, created = update_or_create_user_from_sso('inexistent')
342 self.assertIsNone(user)346 self.assertIsNone(user)
343 self.assertFalse(created)347 self.assertFalse(created)
344348
345 def test_account_data_with_no_email_and_missing_user(self):349 def test_account_data_with_no_email_and_missing_user(self):
346 sso_user_data = self.mock_data_for_account(email='')350 sso_user_data = self.mock_data_for_account(email='')
347 self.mock_get_data.return_value = sso_user_data351 self.mock_get_data.return_value = sso_user_data
348 user, created = update_or_create_user_from_oauth('inexistent')352 user, created = update_or_create_user_from_sso('inexistent')
349 self.assertIsNone(user)353 self.assertIsNone(user)
350 self.assertFalse(created)354 self.assertFalse(created)
351355
352 def test_account_data_with_no_displayname_and_missing_user(self):356 def test_account_data_with_no_displayname_and_missing_user(self):
353 sso_user_data = self.mock_data_for_account(displayname=None)357 sso_user_data = self.mock_data_for_account(displayname=None)
354 self.mock_get_data.return_value = sso_user_data358 self.mock_get_data.return_value = sso_user_data
355 user, created = update_or_create_user_from_oauth('inexistent')359 user, created = update_or_create_user_from_sso('inexistent')
356 self.assertIsNone(user)360 self.assertIsNone(user)
357 self.assertFalse(created)361 self.assertFalse(created)
358362
359 def test_account_data_unverified_and_missing_user(self):363 def test_account_data_unverified_and_missing_user(self):
360 sso_user_data = self.mock_data_for_account(verified=False)364 sso_user_data = self.mock_data_for_account(verified=False)
361 self.mock_get_data.return_value = sso_user_data365 self.mock_get_data.return_value = sso_user_data
362 user, created = update_or_create_user_from_oauth('inexistent')366 user, created = update_or_create_user_from_sso('inexistent')
363 self.assertIsNone(user)367 self.assertIsNone(user)
364 self.assertFalse(created)368 self.assertFalse(created)
365369
@@ -367,7 +371,7 @@
367 self.user.delete()371 self.user.delete()
368 sso_user_data = self.mock_data_for_account()372 sso_user_data = self.mock_data_for_account()
369 self.mock_get_data.return_value = sso_user_data373 self.mock_get_data.return_value = sso_user_data
370 user, created = update_or_create_user_from_oauth('inexistent')374 user, created = update_or_create_user_from_sso('inexistent')
371 self.assertIsNotNone(user)375 self.assertIsNotNone(user)
372 self.assertTrue(created)376 self.assertTrue(created)
373 self.assert_user_data(user, sso_user_data)377 self.assert_user_data(user, sso_user_data)
@@ -380,7 +384,7 @@
380 self.factory.makeUser(384 self.factory.makeUser(
381 first_name='John', last_name='Does', email='user@email.com',385 first_name='John', last_name='Does', email='user@email.com',
382 open_id=open_id)386 open_id=open_id)
383 user, created = update_or_create_user_from_oauth(open_id)387 user, created = update_or_create_user_from_sso(open_id)
384 self.assertIsNotNone(user)388 self.assertIsNotNone(user)
385 self.assertFalse(created)389 self.assertFalse(created)
386 self.assert_user_data(user, sso_user_data)390 self.assert_user_data(user, sso_user_data)
@@ -391,7 +395,7 @@
391 self.factory.makeUser(395 self.factory.makeUser(
392 first_name='John', last_name='Does', email='user@email.com',396 first_name='John', last_name='Does', email='user@email.com',
393 open_id=open_id)397 open_id=open_id)
394 user, created = update_or_create_user_from_oauth(open_id)398 user, created = update_or_create_user_from_sso(open_id)
395 self.assertIsNotNone(user)399 self.assertIsNotNone(user)
396 self.assertFalse(created)400 self.assertFalse(created)
397401
@@ -403,7 +407,7 @@
403 self.factory.makeUser(407 self.factory.makeUser(
404 first_name='John', last_name='Does', email='user@email.com',408 first_name='John', last_name='Does', email='user@email.com',
405 open_id=open_id)409 open_id=open_id)
406 user, created = update_or_create_user_from_oauth(410 user, created = update_or_create_user_from_sso(
407 open_id, update_window=None)411 open_id, update_window=None)
408 self.assertIsNotNone(user)412 self.assertIsNotNone(user)
409 self.assertFalse(created)413 self.assertFalse(created)
@@ -417,7 +421,7 @@
417 user = self.factory.makeUser(421 user = self.factory.makeUser(
418 first_name='John', last_name='Does', email='user@email.com',422 first_name='John', last_name='Does', email='user@email.com',
419 open_id=open_id)423 open_id=open_id)
420 logged_user, created = update_or_create_user_from_oauth(424 logged_user, created = update_or_create_user_from_sso(
421 open_id, update_window=60 * 60 * 24)425 open_id, update_window=60 * 60 * 24)
422 self.assertIsNotNone(logged_user)426 self.assertIsNotNone(logged_user)
423 self.assertFalse(created)427 self.assertFalse(created)
@@ -433,7 +437,7 @@
433 self.factory.makeUser(437 self.factory.makeUser(
434 first_name='John', last_name='Does', email='user@email.com',438 first_name='John', last_name='Does', email='user@email.com',
435 open_id=open_id)439 open_id=open_id)
436 user, created = update_or_create_user_from_oauth(440 user, created = update_or_create_user_from_sso(
437 open_id, update_window=None)441 open_id, update_window=None)
438 self.assertIsNotNone(user)442 self.assertIsNotNone(user)
439 self.assertFalse(created)443 self.assertFalse(created)
@@ -448,7 +452,7 @@
448 self.factory.makeUser(452 self.factory.makeUser(
449 first_name='John', last_name='Does', email='user@email.com',453 first_name='John', last_name='Does', email='user@email.com',
450 open_id=open_id)454 open_id=open_id)
451 user, created = update_or_create_user_from_oauth(455 user, created = update_or_create_user_from_sso(
452 open_id, update_window=None)456 open_id, update_window=None)
453 self.assertIsNotNone(user)457 self.assertIsNotNone(user)
454 self.assertFalse(created)458 self.assertFalse(created)
@@ -463,9 +467,113 @@
463 self.factory.makeUser(467 self.factory.makeUser(
464 first_name='John', last_name='Does', email='user@email.com',468 first_name='John', last_name='Does', email='user@email.com',
465 open_id=open_id)469 open_id=open_id)
466 user, created = update_or_create_user_from_oauth(470 user, created = update_or_create_user_from_sso(
467 open_id, update_window=None)471 open_id, update_window=None)
468 self.assertIsNotNone(user)472 self.assertIsNotNone(user)
469 self.assertFalse(created)473 self.assertFalse(created)
470 self.assertEqual(user.get_full_name(), 'John Does')474 self.assertEqual(user.get_full_name(), 'John Does')
471 self.assertEqual(user.email, 'user@email.com')475 self.assertEqual(user.email, 'user@email.com')
476
477 def test_get_user_from_account_data(self):
478 openid = 'oid1234'
479 sso_user_data = self.mock_data_for_account(openid=openid)
480
481 db_user = self.factory.makeUser(
482 email=sso_user_data['email'], open_id=openid)
483
484 user, created = update_or_create_user_from_sso(
485 openid, account=sso_user_data)
486 self.assertFalse(created)
487 self.assertEqual(user, db_user)
488
489 def test_create_user_from_account_data(self):
490 openid = 'oid1234'
491 sso_user_data = self.mock_data_for_account(openid=openid)
492
493 user, created = update_or_create_user_from_sso(
494 openid, account=sso_user_data)
495 self.assertTrue(created)
496 self.assertEqual(user.email, sso_user_data['email'])
497 self.assertEqual(user.useropenid_set.first().claimed_id,
498 full_claimed_id(openid))
499
500
501class VerifyACLTestCase(TestCaseWithFactory, RequestsDoubleTestCaseMixin):
502
503 def setUp(self):
504 super(VerifyACLTestCase, self).setUp()
505 self.patch_requests()
506 self.sca = WebServices()
507 self.sca_double = SCADouble(self.requests_double)
508 p = patch('core.utilities.logger')
509 self.mock_logger = p.start()
510 self.addCleanup(p.stop)
511
512 def make_request(self):
513 request = RequestFactory().post('/')
514 authorization = 'Macaroon root="%s", discharge="%s"' % (
515 'root-macaroon-data', 'discharge-macaroon-data')
516 request.META['HTTP_AUTHORIZATION'] = authorization
517 return request
518
519 def assert_verify_acl_response(self, data, **kwargs):
520 expected = {
521 'allowed': True,
522 'refresh_required': False,
523 'account': {
524 'openid': 'oid1234',
525 'email': 'user@example.com',
526 'displayname': 'user1234',
527 'verified': True,
528 },
529 'last_auth': '20160515T12:00:00.000',
530 'permissions': ['package_access'],
531 }
532 expected.update(**kwargs)
533 self.assertEqual(data, expected)
534
535 def test_verify_acl_allowed(self):
536 self.sca_double.set_verify_acl_response()
537 request = self.make_request()
538 data = self.sca.verify_acl(request)
539 self.assert_verify_acl_response(data)
540
541 def test_verify_acl_not_allowed(self):
542 self.sca_double.set_verify_acl_response(allowed=False)
543 request = self.make_request()
544 data = self.sca.verify_acl(request)
545 self.assertIsNone(data)
546
547 def test_verify_acl_failed(self):
548 self.requests_double.add_response(
549 self.sca_double.verify_acl_url,
550 method='POST', body='some error', status_code=500)
551 request = self.make_request()
552 data = self.sca.verify_acl(request)
553 self.assertIsNone(data)
554 self.mock_logger.warn.assert_called_once_with(
555 'Failed to verify acl. Response: some error (500)')
556
557 def test_verify_acl_asks_for_right_permission(self):
558 self.sca_double.set_verify_acl_response(permissions=['package_upload'])
559 request = self.make_request()
560 data = self.sca.verify_acl(request)
561 self.assertIsNone(data)
562
563 def test_verify_acl_returns_account_data(self):
564 expected = {
565 'allowed': True,
566 'refresh_required': False,
567 'account': {
568 'openid': 'openid',
569 'displayname': 'username',
570 'email': 'user@example.com',
571 'verified': True,
572 },
573 'last_auth': now().isoformat(),
574 'permissions': ['package_access'],
575 }
576 self.sca_double.set_verify_acl_response(**expected)
577 request = self.make_request()
578 data = self.sca.verify_acl(request)
579 self.assert_verify_acl_response(data, **expected)
472580
=== modified file 'src/core/utilities.py'
--- src/core/utilities.py 2014-10-03 17:06:49 +0000
+++ src/core/utilities.py 2016-05-17 14:32:36 +0000
@@ -1,9 +1,11 @@
1import json
1import sys2import sys
2import time3import time
34
4import logging5import logging
5from httplib2 import ServerNotFoundError6from httplib2 import ServerNotFoundError
67
8import requests
7from django.conf import settings9from django.conf import settings
8from django.contrib.auth.models import User10from django.contrib.auth.models import User
9from django.core.cache import cache11from django.core.cache import cache
@@ -189,6 +191,28 @@
189191
190 return pubs192 return pubs
191193
194 def verify_acl(self, request):
195 data = {
196 'auth_data': json.dumps({
197 'http_url': request.build_absolute_uri(),
198 'http_method': request.method,
199 'authorization': request.META.get('HTTP_AUTHORIZATION', None),
200 }),
201 }
202 url = settings.SCA_HOST_URL.strip('/') + '/dev/api/acl/verify/'
203 response = requests.post(url, data=json.dumps(data))
204 if response.ok:
205 data = response.json()
206 if data.get('allowed', False):
207 if 'package_access' in data.get('permissions', []):
208 # macaroon verified alright
209 return data
210 # acl verification failed, either request was not allowed,
211 # or lacked necessary permissions
212 return
213 logger.warn('Failed to verify acl. Response: {} ({})'.format(
214 response.content, response.status_code))
215
192216
193web_services = WebServices()217web_services = WebServices()
194218
@@ -226,7 +250,7 @@
226250
227251
228@transaction.commit_on_success252@transaction.commit_on_success
229def _get_or_create_user_from_oauth(consumer_key):253def _get_or_create_user_from_sso(consumer_key, account=None):
230 claimed_id = full_claimed_id(consumer_key)254 claimed_id = full_claimed_id(consumer_key)
231 created = False255 created = False
232256
@@ -236,8 +260,9 @@
236 user = None260 user = None
237261
238 if user is None:262 if user is None:
239 account = web_services.get_data_for_account(263 if account is None:
240 openid_identifier=consumer_key)264 account = web_services.get_data_for_account(
265 openid_identifier=consumer_key)
241 account_data = _get_sso_account_data(account)266 account_data = _get_sso_account_data(account)
242267
243 if account_data is None:268 if account_data is None:
@@ -271,8 +296,8 @@
271 return user, created296 return user, created
272297
273298
274def update_or_create_user_from_oauth(299def update_or_create_user_from_sso(
275 consumer_key, update_window=settings.SSO_UPDATE_WINDOW):300 consumer_key, update_window=settings.SSO_UPDATE_WINDOW, account=None):
276 """Update or create user using SSO account data.301 """Update or create user using SSO account data.
277302
278 This function tries to retrieve a `User` from the database using303 This function tries to retrieve a `User` from the database using
@@ -283,15 +308,24 @@
283 given `update_window`. If `update_window` is `None`, SSO data is308 given `update_window`. If `update_window` is `None`, SSO data is
284 always updated.309 always updated.
285310
311 If the optional `account` parameter is provided, SSO will not be contacted
312 for retrieving the initial user data, but the provided `account` data
313 will be used.
314
286 Returns:315 Returns:
287 A two-element tuple, where the first element is the316 A two-element tuple, where the first element is the
288 created/updated user or None and the last one is a boolean317 created/updated user or None and the last one is a boolean
289 especifying whether the user was created or not.318 especifying whether the user was created or not.
290319
291 """320 """
292 user, created = _get_or_create_user_from_oauth(consumer_key)321 user, created = _get_or_create_user_from_sso(
322 consumer_key, account=account)
293323
294 if user is not None:324 if user is not None:
325 # check for update even if account data was provided
326 # account data is only used for the initial user creation;
327 # after that, wether the login is 'fresh' or not (from rnr perspective)
328 # is orthogonal to how the original data was retrieved
295 if update_window is None:329 if update_window is None:
296 should_update = True330 should_update = True
297 else:331 else:
298332
=== modified file 'src/reviewsapp/api/urls.py'
--- src/reviewsapp/api/urls.py 2014-10-09 19:32:45 +0000
+++ src/reviewsapp/api/urls.py 2016-05-17 14:32:36 +0000
@@ -22,7 +22,7 @@
22)22)
2323
24from piston.resource import Resource24from piston.resource import Resource
25from core.api.auth import SSOOAuthAuthentication25from core.api.auth import MacaroonsAuthentication, SSOOAuthAuthentication
26from core.api.decorators import (26from core.api.decorators import (
27 gzip_content,27 gzip_content,
28 vary_only_on_accept,28 vary_only_on_accept,
@@ -44,6 +44,7 @@
44)44)
4545
46auth = SSOOAuthAuthentication(realm="Ratings and Reviews")46auth = SSOOAuthAuthentication(realm="Ratings and Reviews")
47macaroons_auth = MacaroonsAuthentication(realm="Ratings and Reviews")
4748
4849
49# The server status resource should never be cached.50# The server status resource should never be cached.
@@ -99,8 +100,8 @@
99100
100# The following POST handlers have POST data and will not be cached.101# The following POST handlers have POST data and will not be cached.
101# The .__name__ hack is needed to support Django 1.1102# The .__name__ hack is needed to support Django 1.1
102submit_review_resource = CSRFExemptResource(handler=SubmitReviewHandler,103submit_review_resource = CSRFExemptResource(
103 authentication=auth)104 handler=SubmitReviewHandler, authentication=(macaroons_auth, auth))
104modify_review_resource = CSRFExemptResource(handler=ModifyReviewHandler,105modify_review_resource = CSRFExemptResource(handler=ModifyReviewHandler,
105 authentication=auth)106 authentication=auth)
106flag_review_resource = CSRFExemptResource(handler=FlagReviewHandler,107flag_review_resource = CSRFExemptResource(handler=FlagReviewHandler,
107108
=== modified file 'src/reviewsapp/tests/test_handlers.py'
--- src/reviewsapp/tests/test_handlers.py 2016-04-04 04:10:58 +0000
+++ src/reviewsapp/tests/test_handlers.py 2016-05-17 14:32:36 +0000
@@ -56,7 +56,11 @@
56from piston.emitters import Emitter56from piston.emitters import Emitter
57from piston.handler import typemapper57from piston.handler import typemapper
5858
59from core.tests.helpers import assert_no_extra_queries_after59from core.tests.doubles import SCADouble
60from core.tests.helpers import (
61 RequestsDoubleTestCaseMixin,
62 assert_no_extra_queries_after,
63)
60from reviewsapp.api.handlers import (64from reviewsapp.api.handlers import (
61 Django13JSONEncoder,65 Django13JSONEncoder,
62 Django13JSONEmitter,66 Django13JSONEmitter,
@@ -983,7 +987,8 @@
983 self.assertEqual(response['vary'], 'Accept')987 self.assertEqual(response['vary'], 'Accept')
984988
985989
986class SubmitReviewHandlerTestCase(TestCaseWithFactory):990class SubmitReviewHandlerTestCase(TestCaseWithFactory,
991 RequestsDoubleTestCaseMixin):
987992
988 def setUp(self):993 def setUp(self):
989 super(SubmitReviewHandlerTestCase, self).setUp()994 super(SubmitReviewHandlerTestCase, self).setUp()
@@ -1000,13 +1005,26 @@
1000 }1005 }
1001 self.factory.makeArch('i386')1006 self.factory.makeArch('i386')
10021007
1003 def _post_new_review(self, data, user=None, verify_result=True):1008 self.patch_requests()
1009 self.sca_double = SCADouble(self.requests_double)
1010 p = patch('core.utilities.web_services', new=self.sca_double)
1011 p.start()
1012 self.addCleanup(p.stop)
1013
1014 def _post_new_review(self, data, user=None, verify_result=True,
1015 auth='oauth'):
1004 # Post a review as an authenticated user.1016 # Post a review as an authenticated user.
1005 url = reverse('rnr-api-reviews')1017 url = reverse('rnr-api-reviews')
10061018
1007 if user is None:1019 headers = {}
1008 user = self.factory.makeUser()1020 if auth == 'macaroon':
1009 self.client.login(username=user.username, password='test')1021 authorization = 'Macaroon root="%s", discharge="%s"' % (
1022 'root-macaroon-data', 'discharge-macaroon-data')
1023 headers['HTTP_AUTHORIZATION'] = authorization
1024 else:
1025 if user is None:
1026 user = self.factory.makeUser()
1027 self.client.login(username=user.username, password='test')
10101028
1011 # Ensure we don't hit the network for our tests.1029 # Ensure we don't hit the network for our tests.
1012 is_authed_fn = ('core.api.auth.SSOOAuthAuthentication.'1030 is_authed_fn = ('core.api.auth.SSOOAuthAuthentication.'
@@ -1022,7 +1040,8 @@
1022 with patch(sca_verify_fn) as mock_sca_verify_method:1040 with patch(sca_verify_fn) as mock_sca_verify_method:
1023 mock_sca_verify_method.return_value = verify_result1041 mock_sca_verify_method.return_value = verify_result
1024 response = self.client.post(1042 response = self.client.post(
1025 url, data=data, content_type='application/json')1043 url, data=data, content_type='application/json',
1044 **headers)
1026 return response1045 return response
10271046
1028 def test_bogus_data(self):1047 def test_bogus_data(self):
@@ -1058,6 +1077,18 @@
1058 response_dict = simplejson.loads(response.content)1077 response_dict = simplejson.loads(response.content)
1059 self.assertEqual('inkscape', response_dict['package_name'])1078 self.assertEqual('inkscape', response_dict['package_name'])
10601079
1080 def test_create_using_macaroon_auth(self):
1081 self.sca_double.set_verify_acl_response(
1082 is_verified=True, openid='oid1234',
1083 email='user@example.com', displayname='user1234')
1084 response = self._post_new_review(simplejson.dumps(self.required_data),
1085 auth='macaroon')
1086 self.assertEqual(httplib.OK, response.status_code)
1087 self.assertEqual('application/json; charset=utf-8',
1088 response['content-type'])
1089 response_dict = simplejson.loads(response.content)
1090 self.assertEqual('inkscape', response_dict['package_name'])
1091
1061 def test_creates_repository_and_software_item(self):1092 def test_creates_repository_and_software_item(self):
1062 # Submitting a review for a software item or repository of which1093 # Submitting a review for a software item or repository of which
1063 # we don't yet have a record will create those records (after1094 # we don't yet have a record will create those records (after

Subscribers

People subscribed via source and target branches