Merge lp:~ricardokirkner/rnr-server/submit-review-with-macaroons into lp:rnr-server
- submit-review-with-macaroons
- Merge into trunk
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 |
Related bugs: |
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
Description of the change
To post a comment you must log in.
Revision history for this message
Fabián Ezequiel Gallina (fgallina) wrote : | # |
- 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) : | # |
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === modified file 'requirements-devel.txt' | |||
2 | --- requirements-devel.txt 2015-10-26 15:12:50 +0000 | |||
3 | +++ requirements-devel.txt 2016-05-17 14:32:36 +0000 | |||
4 | @@ -10,5 +10,6 @@ | |||
5 | 10 | paste==1.7.5.1 | 10 | paste==1.7.5.1 |
6 | 11 | pep8==1.5.7 | 11 | pep8==1.5.7 |
7 | 12 | piston-mini-client | 12 | piston-mini-client |
8 | 13 | responses==0.5.1 | ||
9 | 13 | testtools==0.9.34 | 14 | testtools==0.9.34 |
10 | 14 | wsgi-intercept==0.5.1 | 15 | wsgi-intercept==0.5.1 |
11 | 15 | 16 | ||
12 | === modified file 'requirements.txt' | |||
13 | --- requirements.txt 2015-10-26 15:11:49 +0000 | |||
14 | +++ requirements.txt 2016-05-17 14:32:36 +0000 | |||
15 | @@ -12,6 +12,7 @@ | |||
16 | 12 | oops-dictconfig==0.0.4 | 12 | oops-dictconfig==0.0.4 |
17 | 13 | oops-wsgi==0.0.11 # bzr+ssh://bazaar.launchpad.net/~ubuntuone-pqm-team/python-oops-wsgi/stable@39 | 13 | oops-wsgi==0.0.11 # bzr+ssh://bazaar.launchpad.net/~ubuntuone-pqm-team/python-oops-wsgi/stable@39 |
18 | 14 | 14 | ||
19 | 15 | requests==2.6.0 | ||
20 | 15 | requests-oauthlib==0.5.0 | 16 | requests-oauthlib==0.5.0 |
21 | 16 | south==1.0.2 | 17 | south==1.0.2 |
22 | 17 | txstatsd==1.0.0 | 18 | txstatsd==1.0.0 |
23 | 18 | 19 | ||
24 | === modified file 'src/clickreviews/forms.py' | |||
25 | --- src/clickreviews/forms.py 2015-04-17 12:39:41 +0000 | |||
26 | +++ src/clickreviews/forms.py 2016-05-17 14:32:36 +0000 | |||
27 | @@ -14,7 +14,7 @@ | |||
28 | 14 | from clickreviews.utilities import verify_click_package_published | 14 | from clickreviews.utilities import verify_click_package_published |
29 | 15 | from core.forms import JSONErrorMixin | 15 | from core.forms import JSONErrorMixin |
30 | 16 | from core.models import BaseModeration | 16 | from core.models import BaseModeration |
32 | 17 | from core.utilities import update_or_create_user_from_oauth | 17 | from core.utilities import update_or_create_user_from_sso |
33 | 18 | 18 | ||
34 | 19 | 19 | ||
35 | 20 | class ClickPackageReviewForm(forms.ModelForm, JSONErrorMixin): | 20 | class ClickPackageReviewForm(forms.ModelForm, JSONErrorMixin): |
36 | @@ -239,7 +239,7 @@ | |||
37 | 239 | 239 | ||
38 | 240 | def clean_moderator(self): | 240 | def clean_moderator(self): |
39 | 241 | openid = self.cleaned_data['moderator'] | 241 | openid = self.cleaned_data['moderator'] |
41 | 242 | user, __ = update_or_create_user_from_oauth(openid) | 242 | user, __ = update_or_create_user_from_sso(openid) |
42 | 243 | if user is None: | 243 | if user is None: |
43 | 244 | self.add_error( | 244 | self.add_error( |
44 | 245 | 'moderator', _('User given as moderator not found.')) | 245 | 'moderator', _('User given as moderator not found.')) |
45 | 246 | 246 | ||
46 | === modified file 'src/core/api/auth/__init__.py' | |||
47 | --- src/core/api/auth/__init__.py 2014-09-24 14:57:52 +0000 | |||
48 | +++ src/core/api/auth/__init__.py 2016-05-17 14:32:36 +0000 | |||
49 | @@ -1,8 +1,10 @@ | |||
50 | 1 | from core.api.auth.delegated_sso import DelegatedSSOAuthentication | 1 | from core.api.auth.delegated_sso import DelegatedSSOAuthentication |
51 | 2 | from core.api.auth.macaroon import MacaroonsAuthentication | ||
52 | 2 | from core.api.auth.sso import SSOOAuthAuthentication | 3 | from core.api.auth.sso import SSOOAuthAuthentication |
53 | 3 | 4 | ||
54 | 4 | 5 | ||
55 | 5 | __all__ = [ | 6 | __all__ = [ |
56 | 6 | 'DelegatedSSOAuthentication', | 7 | 'DelegatedSSOAuthentication', |
57 | 8 | 'MacaroonsAuthentication', | ||
58 | 7 | 'SSOOAuthAuthentication', | 9 | 'SSOOAuthAuthentication', |
59 | 8 | ] | 10 | ] |
60 | 9 | 11 | ||
61 | === added file 'src/core/api/auth/macaroon.py' | |||
62 | --- src/core/api/auth/macaroon.py 1970-01-01 00:00:00 +0000 | |||
63 | +++ src/core/api/auth/macaroon.py 2016-05-17 14:32:36 +0000 | |||
64 | @@ -0,0 +1,82 @@ | |||
65 | 1 | # This file is part of Software Center Ratings and Reviews | ||
66 | 2 | # Copyright (C) 2016 Canonical Ltd. | ||
67 | 3 | # | ||
68 | 4 | # This program is free software: you can redistribute it and/or modify | ||
69 | 5 | # it under the terms of the GNU Affero General Public License as | ||
70 | 6 | # published by the Free Software Foundation, either version 3 of the | ||
71 | 7 | # License, or (at your option) any later version. | ||
72 | 8 | # | ||
73 | 9 | # This program is distributed in the hope that it will be useful, | ||
74 | 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
75 | 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
76 | 12 | # GNU Affero General Public License for more details. | ||
77 | 13 | # | ||
78 | 14 | # You should have received a copy of the GNU Affero General Public License | ||
79 | 15 | # along with this program. If not, see <http://www.gnu.org/licenses/>. | ||
80 | 16 | |||
81 | 17 | """Macaroon authenticator for Piston.""" | ||
82 | 18 | |||
83 | 19 | from __future__ import absolute_import | ||
84 | 20 | |||
85 | 21 | import logging | ||
86 | 22 | |||
87 | 23 | from django.http import HttpResponse | ||
88 | 24 | from piston.authentication import OAuthAuthentication | ||
89 | 25 | |||
90 | 26 | from core.utilities import ( | ||
91 | 27 | update_or_create_user_from_sso, | ||
92 | 28 | web_services, | ||
93 | 29 | ) | ||
94 | 30 | |||
95 | 31 | |||
96 | 32 | logger = logging.getLogger(__name__) | ||
97 | 33 | |||
98 | 34 | |||
99 | 35 | def has_macaroon_bits(authorization): | ||
100 | 36 | """Return whether there are Macaroon bits in authorization. | ||
101 | 37 | |||
102 | 38 | Macaroon bits are detected by ensuring the authorization starts with | ||
103 | 39 | 'Macaroon'. | ||
104 | 40 | |||
105 | 41 | """ | ||
106 | 42 | has_bits = ( | ||
107 | 43 | authorization and authorization.split()[0].lower() == 'macaroon') | ||
108 | 44 | return has_bits | ||
109 | 45 | |||
110 | 46 | |||
111 | 47 | class MacaroonsAuthentication(OAuthAuthentication): | ||
112 | 48 | """ This class is a Piston Authentication class. | ||
113 | 49 | |||
114 | 50 | See http://bitbucket.org/jespern/django-piston/wiki/Documentation for | ||
115 | 51 | more info. | ||
116 | 52 | """ | ||
117 | 53 | def __init__(self, realm='API'): | ||
118 | 54 | self.realm = realm | ||
119 | 55 | |||
120 | 56 | def challenge(self): | ||
121 | 57 | """ We define our own challenge so that we can provide our own realm | ||
122 | 58 | and return a simple error message instead of rendering a template. | ||
123 | 59 | """ | ||
124 | 60 | resp = HttpResponse("Authorization Required") | ||
125 | 61 | resp['WWW-Authenticate'] = 'Macaroon realm="%s"' % self.realm | ||
126 | 62 | resp.status_code = 401 | ||
127 | 63 | return resp | ||
128 | 64 | |||
129 | 65 | def is_authenticated(self, request): | ||
130 | 66 | authorization = request.META.get('HTTP_AUTHORIZATION', None) | ||
131 | 67 | if not has_macaroon_bits(authorization): | ||
132 | 68 | # skip network request as macaroon headers are missing | ||
133 | 69 | return False | ||
134 | 70 | data = web_services.verify_acl(request) | ||
135 | 71 | account = data and data.get('account', {}) or {} | ||
136 | 72 | verified = account.get('verified', False) | ||
137 | 73 | if not verified: | ||
138 | 74 | return False | ||
139 | 75 | |||
140 | 76 | # get or create user account | ||
141 | 77 | user, _ = update_or_create_user_from_sso( | ||
142 | 78 | account.get('openid'), account=account) | ||
143 | 79 | |||
144 | 80 | # inject user into request | ||
145 | 81 | request.user = user | ||
146 | 82 | return True | ||
147 | 0 | 83 | ||
148 | === modified file 'src/core/api/auth/sso.py' | |||
149 | --- src/core/api/auth/sso.py 2015-10-09 18:54:55 +0000 | |||
150 | +++ src/core/api/auth/sso.py 2016-05-17 14:32:36 +0000 | |||
151 | @@ -25,7 +25,7 @@ | |||
152 | 25 | 25 | ||
153 | 26 | from core.utilities import ( | 26 | from core.utilities import ( |
154 | 27 | web_services, | 27 | web_services, |
156 | 28 | update_or_create_user_from_oauth, | 28 | update_or_create_user_from_sso, |
157 | 29 | ) | 29 | ) |
158 | 30 | 30 | ||
159 | 31 | 31 | ||
160 | @@ -93,7 +93,7 @@ | |||
161 | 93 | 93 | ||
162 | 94 | openid = oauth_request.get_parameter('oauth_consumer_key') | 94 | openid = oauth_request.get_parameter('oauth_consumer_key') |
163 | 95 | 95 | ||
165 | 96 | user, _ = update_or_create_user_from_oauth(openid) | 96 | user, _ = update_or_create_user_from_sso(openid) |
166 | 97 | if user is not None: | 97 | if user is not None: |
167 | 98 | request.user = user | 98 | request.user = user |
168 | 99 | else: | 99 | else: |
169 | 100 | 100 | ||
170 | === added file 'src/core/tests/doubles.py' | |||
171 | --- src/core/tests/doubles.py 1970-01-01 00:00:00 +0000 | |||
172 | +++ src/core/tests/doubles.py 2016-05-17 14:32:36 +0000 | |||
173 | @@ -0,0 +1,119 @@ | |||
174 | 1 | from __future__ import absolute_import, unicode_literals | ||
175 | 2 | |||
176 | 3 | import json | ||
177 | 4 | |||
178 | 5 | import responses | ||
179 | 6 | from django.conf import settings | ||
180 | 7 | from requests import PreparedRequest | ||
181 | 8 | |||
182 | 9 | from core.utilities import WebServices | ||
183 | 10 | |||
184 | 11 | |||
185 | 12 | # XXX: RequestsDouble and RequestsDoubleTestCaseMixin come from | ||
186 | 13 | # appstore-common, this copypasta (with modifications) is an attempt | ||
187 | 14 | # to generalize it a bit and later on move the code into its own | ||
188 | 15 | # module and reuse it across projects. | ||
189 | 16 | class RequestsDouble(object): | ||
190 | 17 | |||
191 | 18 | def start(self): | ||
192 | 19 | self.reset() | ||
193 | 20 | responses.start() | ||
194 | 21 | |||
195 | 22 | def stop(self): | ||
196 | 23 | # Stopping does not triggers a reset and it should because | ||
197 | 24 | # responses dirty state can become a problem for other tests. | ||
198 | 25 | try: | ||
199 | 26 | self.reset() | ||
200 | 27 | responses.stop() | ||
201 | 28 | except RuntimeError: | ||
202 | 29 | raise RuntimeError( | ||
203 | 30 | 'Calling stop on a RequestDouble that is not started.' | ||
204 | 31 | 'This may be because you are instantiating your own instead ' | ||
205 | 32 | 'of reusing the RequestDouble from your TestCase') | ||
206 | 33 | |||
207 | 34 | def reset(self): | ||
208 | 35 | responses.reset() | ||
209 | 36 | |||
210 | 37 | def reset_calls(self): | ||
211 | 38 | responses.calls.reset() | ||
212 | 39 | |||
213 | 40 | def make_body(self, data=None, files=None): | ||
214 | 41 | req = PreparedRequest() | ||
215 | 42 | # need to define headers before body | ||
216 | 43 | req.prepare_headers({}) | ||
217 | 44 | req.prepare_body(data=data, files=files) | ||
218 | 45 | return req.body | ||
219 | 46 | |||
220 | 47 | def add_response(self, url, status_code=200, body='', | ||
221 | 48 | match_querystring=False, method='GET', | ||
222 | 49 | callback=None, reset=False): | ||
223 | 50 | if reset: | ||
224 | 51 | self.reset() | ||
225 | 52 | if callback: | ||
226 | 53 | responses.add_callback(method, url, callback=callback, | ||
227 | 54 | match_querystring=match_querystring) | ||
228 | 55 | else: | ||
229 | 56 | responses.add(method, url, match_querystring=match_querystring, | ||
230 | 57 | status=status_code, body=body) | ||
231 | 58 | |||
232 | 59 | def _call_has_headers(self, call, headers): | ||
233 | 60 | has_headers = True | ||
234 | 61 | |||
235 | 62 | if headers is not None: | ||
236 | 63 | # make sure all given headers were included in call | ||
237 | 64 | for key, value in headers.items(): | ||
238 | 65 | if call.request.headers.get(key) != value: | ||
239 | 66 | has_headers = False | ||
240 | 67 | break | ||
241 | 68 | |||
242 | 69 | return has_headers | ||
243 | 70 | |||
244 | 71 | def find_call(self, url, headers=None, body=None, method=None): | ||
245 | 72 | """Find calls for url optionally matching headers, body and method. | ||
246 | 73 | |||
247 | 74 | The given headers can be a subset of the actual call headers, | ||
248 | 75 | body must be an exact match. | ||
249 | 76 | |||
250 | 77 | """ | ||
251 | 78 | for call in responses.calls: | ||
252 | 79 | test_body = call.request.body if body is None else body | ||
253 | 80 | if (call.request.url == url | ||
254 | 81 | and self._call_has_headers(call, headers) | ||
255 | 82 | and test_body == call.request.body): | ||
256 | 83 | if method is not None and call.request.method != method: | ||
257 | 84 | continue | ||
258 | 85 | return (url, call.request.headers, call.request.body) | ||
259 | 86 | |||
260 | 87 | def find_calls(self, url, method=None): | ||
261 | 88 | calls = (c for c in responses.calls if c.request.url == url) | ||
262 | 89 | if method: | ||
263 | 90 | calls = (c for c in calls if c.request.method == method) | ||
264 | 91 | return [(url, c.request.headers, c.request.body) for c in calls] | ||
265 | 92 | |||
266 | 93 | |||
267 | 94 | class SCADouble(WebServices): | ||
268 | 95 | |||
269 | 96 | def __init__(self, requests_double, *args, **kwargs): | ||
270 | 97 | WebServices.__init__(self, *args, **kwargs) | ||
271 | 98 | self.requests_double = requests_double | ||
272 | 99 | |||
273 | 100 | self.verify_acl_url = ( | ||
274 | 101 | settings.SCA_HOST_URL.strip('/') + '/dev/api/acl/verify/') | ||
275 | 102 | |||
276 | 103 | def set_verify_acl_response(self, **kwargs): | ||
277 | 104 | body = { | ||
278 | 105 | 'allowed': True, | ||
279 | 106 | 'refresh_required': False, | ||
280 | 107 | 'account': { | ||
281 | 108 | 'openid': 'oid1234', | ||
282 | 109 | 'email': 'user@example.com', | ||
283 | 110 | 'displayname': 'user1234', | ||
284 | 111 | 'verified': True, | ||
285 | 112 | }, | ||
286 | 113 | 'last_auth': '20160515T12:00:00.000', | ||
287 | 114 | 'permissions': ['package_access'], | ||
288 | 115 | } | ||
289 | 116 | body.update(**kwargs) | ||
290 | 117 | self.requests_double.add_response( | ||
291 | 118 | self.verify_acl_url, method='POST', | ||
292 | 119 | body=json.dumps(body)) | ||
293 | 0 | 120 | ||
294 | === modified file 'src/core/tests/helpers.py' | |||
295 | --- src/core/tests/helpers.py 2015-04-22 13:24:20 +0000 | |||
296 | +++ src/core/tests/helpers.py 2016-05-17 14:32:36 +0000 | |||
297 | @@ -5,6 +5,7 @@ | |||
298 | 5 | from mock import patch | 5 | from mock import patch |
299 | 6 | 6 | ||
300 | 7 | from core.utilities import web_services | 7 | from core.utilities import web_services |
301 | 8 | from core.tests.doubles import RequestsDouble | ||
302 | 8 | 9 | ||
303 | 9 | 10 | ||
304 | 10 | class SSOTestCaseMixin(object): | 11 | class SSOTestCaseMixin(object): |
305 | @@ -83,3 +84,44 @@ | |||
306 | 83 | message += "\n\n\nSecond queries:\n" | 84 | message += "\n\n\nSecond queries:\n" |
307 | 84 | message += format_queries(second.captured_queries) | 85 | message += format_queries(second.captured_queries) |
308 | 85 | raise AssertionError(message) | 86 | raise AssertionError(message) |
309 | 87 | |||
310 | 88 | |||
311 | 89 | # Why a mixin? Because we want to be able to plug this functionality | ||
312 | 90 | # to any of the variants of TestCases the user may have without | ||
313 | 91 | # relying on hierarchy (e.g. `unittest.TestCase`, | ||
314 | 92 | # `django.test.TestCase`). An effect of this is that `patch_requests` | ||
315 | 93 | # must be explicitly called to activate requests double functionality, | ||
316 | 94 | # which may be a good thing if you want fine-grained control. | ||
317 | 95 | class RequestsDoubleTestCaseMixin(object): | ||
318 | 96 | """Mixin to add requests double functionality. | ||
319 | 97 | |||
320 | 98 | To enable it, `patch_requests` must be called prior your tests are | ||
321 | 99 | executed, so generally this is a good thing to be placed in the | ||
322 | 100 | setUp method of your `TestCase` classes unless you wish more | ||
323 | 101 | fine-grained control. | ||
324 | 102 | """ | ||
325 | 103 | |||
326 | 104 | def patch_requests(self): | ||
327 | 105 | """Enable requests double. | ||
328 | 106 | |||
329 | 107 | Takes care of cleaning up itself after a test is run. | ||
330 | 108 | """ | ||
331 | 109 | self.requests_double = RequestsDouble() | ||
332 | 110 | self.requests_double.start() | ||
333 | 111 | self.addCleanup(self.requests_double.stop) | ||
334 | 112 | |||
335 | 113 | def patch_choose_boundary(self, boundary='theboundary'): | ||
336 | 114 | return self.patch( | ||
337 | 115 | 'requests.packages.urllib3.filepost.choose_boundary', | ||
338 | 116 | return_value=boundary.encode('utf-8')) | ||
339 | 117 | |||
340 | 118 | def assert_call(self, url, headers=None, body=None, method=None): | ||
341 | 119 | """Assert call to url and headers was executed.""" | ||
342 | 120 | call = self.requests_double.find_call( | ||
343 | 121 | url, headers, body, method=method) | ||
344 | 122 | if call is None: | ||
345 | 123 | self.fail( | ||
346 | 124 | "No matching call found. url=%s headers=%s body=%s " | ||
347 | 125 | "method=%s" % (url, headers, body, method)) | ||
348 | 126 | |||
349 | 127 | |||
350 | 86 | 128 | ||
351 | === modified file 'src/core/tests/test_api_auth.py' | |||
352 | --- src/core/tests/test_api_auth.py 2015-10-09 19:31:57 +0000 | |||
353 | +++ src/core/tests/test_api_auth.py 2016-05-17 14:32:36 +0000 | |||
354 | @@ -37,9 +37,11 @@ | |||
355 | 37 | SSOOAuthAuthentication, | 37 | SSOOAuthAuthentication, |
356 | 38 | delegated_sso, | 38 | delegated_sso, |
357 | 39 | ) | 39 | ) |
358 | 40 | from core.api.auth.macaroon import MacaroonsAuthentication, has_macaroon_bits | ||
359 | 40 | from core.api.auth.sso import has_oauth_bits | 41 | from core.api.auth.sso import has_oauth_bits |
360 | 42 | from core.tests.doubles import SCADouble | ||
361 | 41 | from core.tests.factory import TestCaseWithFactory | 43 | from core.tests.factory import TestCaseWithFactory |
363 | 42 | from core.tests.helpers import SSOTestCaseMixin | 44 | from core.tests.helpers import RequestsDoubleTestCaseMixin, SSOTestCaseMixin |
364 | 43 | from core.utilities import full_claimed_id | 45 | from core.utilities import full_claimed_id |
365 | 44 | 46 | ||
366 | 45 | 47 | ||
367 | @@ -53,6 +55,7 @@ | |||
368 | 53 | self.method = method | 55 | self.method = method |
369 | 54 | self.environ = self.META | 56 | self.environ = self.META |
370 | 55 | self.uri = uri | 57 | self.uri = uri |
371 | 58 | self.user = None | ||
372 | 56 | 59 | ||
373 | 57 | def build_absolute_uri(self): | 60 | def build_absolute_uri(self): |
374 | 58 | return self.uri | 61 | return self.uri |
375 | @@ -134,6 +137,17 @@ | |||
376 | 134 | self.assertFalse(result, self.no_oauth % not_allowed) | 137 | self.assertFalse(result, self.no_oauth % not_allowed) |
377 | 135 | 138 | ||
378 | 136 | 139 | ||
379 | 140 | class HasMacaroonBitsTestCase(TestCase): | ||
380 | 141 | |||
381 | 142 | def test_has_macaroon_bits(self): | ||
382 | 143 | for authorization in ('Macaroon zaraza', 'macaroon zaraza'): | ||
383 | 144 | self.assertTrue(has_macaroon_bits(authorization)) | ||
384 | 145 | |||
385 | 146 | def test_has_not_macaroon_bits(self): | ||
386 | 147 | for authorization in ('OAuth zaraza', 'Auth macaroon zaraza'): | ||
387 | 148 | self.assertFalse(has_macaroon_bits(authorization)) | ||
388 | 149 | |||
389 | 150 | |||
390 | 137 | class SSOOAuthAuthenticationTestCase(BaseAuthenticationTestCase): | 151 | class SSOOAuthAuthenticationTestCase(BaseAuthenticationTestCase): |
391 | 138 | 152 | ||
392 | 139 | def _get_auth(self): | 153 | def _get_auth(self): |
393 | @@ -221,7 +235,7 @@ | |||
394 | 221 | self.user.delete() | 235 | self.user.delete() |
395 | 222 | self.factory.makeUser() | 236 | self.factory.makeUser() |
396 | 223 | 237 | ||
398 | 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' |
399 | 225 | with patch(get_method) as mock_get_user: | 239 | with patch(get_method) as mock_get_user: |
400 | 226 | mock_get_user.return_value = (None, False) | 240 | mock_get_user.return_value = (None, False) |
401 | 227 | response = self.get_resource(request) | 241 | response = self.get_resource(request) |
402 | @@ -669,3 +683,108 @@ | |||
403 | 669 | request = self.make_request('POST', self.signature, body='wrong') | 683 | request = self.make_request('POST', self.signature, body='wrong') |
404 | 670 | response = self.post_resource(request) | 684 | response = self.post_resource(request) |
405 | 671 | self.assertEqual(httplib.UNAUTHORIZED, response.status_code) | 685 | self.assertEqual(httplib.UNAUTHORIZED, response.status_code) |
406 | 686 | |||
407 | 687 | |||
408 | 688 | class MacaroonsAuthenticationTestCase(BaseAuthenticationTestCase, | ||
409 | 689 | RequestsDoubleTestCaseMixin): | ||
410 | 690 | |||
411 | 691 | def setUp(self): | ||
412 | 692 | super(MacaroonsAuthenticationTestCase, self).setUp() | ||
413 | 693 | self.patch_requests() | ||
414 | 694 | |||
415 | 695 | self.sca_double = SCADouble(self.requests_double) | ||
416 | 696 | p = patch('core.utilities.web_services', new=self.sca_double) | ||
417 | 697 | p.start() | ||
418 | 698 | self.addCleanup(p.stop) | ||
419 | 699 | |||
420 | 700 | def _get_auth(self): | ||
421 | 701 | return MacaroonsAuthentication(realm="Ratings and Reviews") | ||
422 | 702 | |||
423 | 703 | def make_request(self): | ||
424 | 704 | request = MockRequest(method='POST') | ||
425 | 705 | authorization = 'Macaroon root="%s", discharge="%s"' % ( | ||
426 | 706 | 'root-macaroon-data', 'discharge-macaroon-data') | ||
427 | 707 | request.META['HTTP_AUTHORIZATION'] = authorization | ||
428 | 708 | return request | ||
429 | 709 | |||
430 | 710 | def test_no_auth_fails(self): | ||
431 | 711 | request = MockRequest() | ||
432 | 712 | assert not request.META.get('HTTP_AUTHORIZATION') | ||
433 | 713 | |||
434 | 714 | response = self.post_resource(request) | ||
435 | 715 | |||
436 | 716 | self.assertEqual(httplib.UNAUTHORIZED, response.status_code) | ||
437 | 717 | self.assertEqual('Macaroon realm="Ratings and Reviews"', | ||
438 | 718 | response['WWW-Authenticate']) | ||
439 | 719 | |||
440 | 720 | def test_no_macaroon_authorization_header(self): | ||
441 | 721 | request = self.make_request() | ||
442 | 722 | request.META['HTTP_AUTHORIZATION'] = 'Basic realm="foo"' | ||
443 | 723 | |||
444 | 724 | response = self.post_resource(request) | ||
445 | 725 | self.assertEqual(httplib.UNAUTHORIZED, response.status_code) | ||
446 | 726 | |||
447 | 727 | def test_success(self): | ||
448 | 728 | self.sca_double.set_verify_acl_response() | ||
449 | 729 | request = self.make_request() | ||
450 | 730 | |||
451 | 731 | response = self.post_resource(request) | ||
452 | 732 | self.assertEqual(httplib.CREATED, response.status_code) | ||
453 | 733 | |||
454 | 734 | def test_failure(self): | ||
455 | 735 | self.requests_double.add_response( | ||
456 | 736 | self.sca_double.verify_acl_url, method='POST', status_code=400) | ||
457 | 737 | request = self.make_request() | ||
458 | 738 | |||
459 | 739 | response = self.post_resource(request) | ||
460 | 740 | self.assertEqual(httplib.UNAUTHORIZED, response.status_code) | ||
461 | 741 | |||
462 | 742 | def test_successful_authentication_injects_user(self): | ||
463 | 743 | self.sca_double.set_verify_acl_response( | ||
464 | 744 | openid='oid1234', displayname='user1234', | ||
465 | 745 | email='user@example.com', is_verified=True) | ||
466 | 746 | request = self.make_request() | ||
467 | 747 | |||
468 | 748 | response = self.auth.is_authenticated(request) | ||
469 | 749 | self.assertTrue(response) | ||
470 | 750 | |||
471 | 751 | # a new user was created | ||
472 | 752 | self.assertEqual(User.objects.count(), 2) | ||
473 | 753 | user = request.user | ||
474 | 754 | self.assertIsInstance(user, User) | ||
475 | 755 | self.assertEqual(user.username, 'oid1234') | ||
476 | 756 | self.assertEqual(user.last_name, 'user1234') | ||
477 | 757 | self.assertEqual(user.email, 'user@example.com') | ||
478 | 758 | openid = user.useropenid_set.get().claimed_id.split('/')[-1] | ||
479 | 759 | self.assertEqual(openid, 'oid1234') | ||
480 | 760 | |||
481 | 761 | def test_do_not_create_user_if_exists(self): | ||
482 | 762 | account = { | ||
483 | 763 | 'openid': self.openid, | ||
484 | 764 | 'displayname': 'user1234', | ||
485 | 765 | 'email': 'user@example.com', | ||
486 | 766 | 'verified': True, | ||
487 | 767 | } | ||
488 | 768 | self.sca_double.set_verify_acl_response(account=account) | ||
489 | 769 | request = self.make_request() | ||
490 | 770 | |||
491 | 771 | response = self.auth.is_authenticated(request) | ||
492 | 772 | self.assertTrue(response) | ||
493 | 773 | |||
494 | 774 | self.assertEqual(request.user, self.user) | ||
495 | 775 | self.assertEqual(User.objects.count(), 1) | ||
496 | 776 | |||
497 | 777 | def test_do_not_create_user_if_user_not_verified(self): | ||
498 | 778 | account = { | ||
499 | 779 | 'openid': self.openid, | ||
500 | 780 | 'displayname': 'user1234', | ||
501 | 781 | 'email': 'user@example.com', | ||
502 | 782 | 'verified': False, | ||
503 | 783 | } | ||
504 | 784 | self.sca_double.set_verify_acl_response(account=account) | ||
505 | 785 | request = self.make_request() | ||
506 | 786 | |||
507 | 787 | response = self.auth.is_authenticated(request) | ||
508 | 788 | self.assertFalse(response) | ||
509 | 789 | |||
510 | 790 | self.assertIsNone(request.user) | ||
511 | 672 | 791 | ||
512 | === modified file 'src/core/tests/test_utilities.py' | |||
513 | --- src/core/tests/test_utilities.py 2015-04-22 19:38:37 +0000 | |||
514 | +++ src/core/tests/test_utilities.py 2016-05-17 14:32:36 +0000 | |||
515 | @@ -3,16 +3,20 @@ | |||
516 | 3 | from django.conf import settings | 3 | from django.conf import settings |
517 | 4 | from django.core.cache import cache | 4 | from django.core.cache import cache |
518 | 5 | from django.test import TestCase | 5 | from django.test import TestCase |
519 | 6 | from django.test.client import RequestFactory | ||
520 | 6 | from django.test.utils import override_settings | 7 | from django.test.utils import override_settings |
521 | 8 | from django.utils.timezone import now | ||
522 | 7 | from launchpadlib.errors import HTTPError | 9 | from launchpadlib.errors import HTTPError |
523 | 8 | from mock import patch, Mock | 10 | from mock import patch, Mock |
524 | 9 | from piston_mini_client import PistonResponseObject | 11 | from piston_mini_client import PistonResponseObject |
525 | 10 | 12 | ||
526 | 13 | from core.tests.doubles import SCADouble | ||
527 | 14 | from core.tests.helpers import RequestsDoubleTestCaseMixin | ||
528 | 11 | from core.tests.factory import TestCaseWithFactory | 15 | from core.tests.factory import TestCaseWithFactory |
529 | 12 | from core.utilities import ( | 16 | from core.utilities import ( |
530 | 13 | WebServices, | 17 | WebServices, |
531 | 14 | full_claimed_id, | 18 | full_claimed_id, |
533 | 15 | update_or_create_user_from_oauth, | 19 | update_or_create_user_from_sso, |
534 | 16 | web_services, | 20 | web_services, |
535 | 17 | ) | 21 | ) |
536 | 18 | 22 | ||
537 | @@ -305,10 +309,10 @@ | |||
538 | 305 | self.assertTrue(claimed_id.endswith(consumer_key)) | 309 | self.assertTrue(claimed_id.endswith(consumer_key)) |
539 | 306 | 310 | ||
540 | 307 | 311 | ||
542 | 308 | class UpdateOrCreateUserFromOauthTestCase(TestCaseWithFactory): | 312 | class UpdateOrCreateUserFromSSOTestCase(TestCaseWithFactory): |
543 | 309 | 313 | ||
544 | 310 | def setUp(self): | 314 | def setUp(self): |
546 | 311 | super(UpdateOrCreateUserFromOauthTestCase, self).setUp() | 315 | super(UpdateOrCreateUserFromSSOTestCase, self).setUp() |
547 | 312 | self.user = self.factory.makeUser() | 316 | self.user = self.factory.makeUser() |
548 | 313 | p = patch('core.utilities.web_services') | 317 | p = patch('core.utilities.web_services') |
549 | 314 | self.mock_web_services = p.start() | 318 | self.mock_web_services = p.start() |
550 | @@ -338,28 +342,28 @@ | |||
551 | 338 | 342 | ||
552 | 339 | def test_missing_account_data_and_missing_user(self): | 343 | def test_missing_account_data_and_missing_user(self): |
553 | 340 | self.mock_get_data.return_value = None | 344 | self.mock_get_data.return_value = None |
555 | 341 | user, created = update_or_create_user_from_oauth('inexistent') | 345 | user, created = update_or_create_user_from_sso('inexistent') |
556 | 342 | self.assertIsNone(user) | 346 | self.assertIsNone(user) |
557 | 343 | self.assertFalse(created) | 347 | self.assertFalse(created) |
558 | 344 | 348 | ||
559 | 345 | def test_account_data_with_no_email_and_missing_user(self): | 349 | def test_account_data_with_no_email_and_missing_user(self): |
560 | 346 | sso_user_data = self.mock_data_for_account(email='') | 350 | sso_user_data = self.mock_data_for_account(email='') |
561 | 347 | self.mock_get_data.return_value = sso_user_data | 351 | self.mock_get_data.return_value = sso_user_data |
563 | 348 | user, created = update_or_create_user_from_oauth('inexistent') | 352 | user, created = update_or_create_user_from_sso('inexistent') |
564 | 349 | self.assertIsNone(user) | 353 | self.assertIsNone(user) |
565 | 350 | self.assertFalse(created) | 354 | self.assertFalse(created) |
566 | 351 | 355 | ||
567 | 352 | def test_account_data_with_no_displayname_and_missing_user(self): | 356 | def test_account_data_with_no_displayname_and_missing_user(self): |
568 | 353 | sso_user_data = self.mock_data_for_account(displayname=None) | 357 | sso_user_data = self.mock_data_for_account(displayname=None) |
569 | 354 | self.mock_get_data.return_value = sso_user_data | 358 | self.mock_get_data.return_value = sso_user_data |
571 | 355 | user, created = update_or_create_user_from_oauth('inexistent') | 359 | user, created = update_or_create_user_from_sso('inexistent') |
572 | 356 | self.assertIsNone(user) | 360 | self.assertIsNone(user) |
573 | 357 | self.assertFalse(created) | 361 | self.assertFalse(created) |
574 | 358 | 362 | ||
575 | 359 | def test_account_data_unverified_and_missing_user(self): | 363 | def test_account_data_unverified_and_missing_user(self): |
576 | 360 | sso_user_data = self.mock_data_for_account(verified=False) | 364 | sso_user_data = self.mock_data_for_account(verified=False) |
577 | 361 | self.mock_get_data.return_value = sso_user_data | 365 | self.mock_get_data.return_value = sso_user_data |
579 | 362 | user, created = update_or_create_user_from_oauth('inexistent') | 366 | user, created = update_or_create_user_from_sso('inexistent') |
580 | 363 | self.assertIsNone(user) | 367 | self.assertIsNone(user) |
581 | 364 | self.assertFalse(created) | 368 | self.assertFalse(created) |
582 | 365 | 369 | ||
583 | @@ -367,7 +371,7 @@ | |||
584 | 367 | self.user.delete() | 371 | self.user.delete() |
585 | 368 | sso_user_data = self.mock_data_for_account() | 372 | sso_user_data = self.mock_data_for_account() |
586 | 369 | self.mock_get_data.return_value = sso_user_data | 373 | self.mock_get_data.return_value = sso_user_data |
588 | 370 | user, created = update_or_create_user_from_oauth('inexistent') | 374 | user, created = update_or_create_user_from_sso('inexistent') |
589 | 371 | self.assertIsNotNone(user) | 375 | self.assertIsNotNone(user) |
590 | 372 | self.assertTrue(created) | 376 | self.assertTrue(created) |
591 | 373 | self.assert_user_data(user, sso_user_data) | 377 | self.assert_user_data(user, sso_user_data) |
592 | @@ -380,7 +384,7 @@ | |||
593 | 380 | self.factory.makeUser( | 384 | self.factory.makeUser( |
594 | 381 | first_name='John', last_name='Does', email='user@email.com', | 385 | first_name='John', last_name='Does', email='user@email.com', |
595 | 382 | open_id=open_id) | 386 | open_id=open_id) |
597 | 383 | user, created = update_or_create_user_from_oauth(open_id) | 387 | user, created = update_or_create_user_from_sso(open_id) |
598 | 384 | self.assertIsNotNone(user) | 388 | self.assertIsNotNone(user) |
599 | 385 | self.assertFalse(created) | 389 | self.assertFalse(created) |
600 | 386 | self.assert_user_data(user, sso_user_data) | 390 | self.assert_user_data(user, sso_user_data) |
601 | @@ -391,7 +395,7 @@ | |||
602 | 391 | self.factory.makeUser( | 395 | self.factory.makeUser( |
603 | 392 | first_name='John', last_name='Does', email='user@email.com', | 396 | first_name='John', last_name='Does', email='user@email.com', |
604 | 393 | open_id=open_id) | 397 | open_id=open_id) |
606 | 394 | user, created = update_or_create_user_from_oauth(open_id) | 398 | user, created = update_or_create_user_from_sso(open_id) |
607 | 395 | self.assertIsNotNone(user) | 399 | self.assertIsNotNone(user) |
608 | 396 | self.assertFalse(created) | 400 | self.assertFalse(created) |
609 | 397 | 401 | ||
610 | @@ -403,7 +407,7 @@ | |||
611 | 403 | self.factory.makeUser( | 407 | self.factory.makeUser( |
612 | 404 | first_name='John', last_name='Does', email='user@email.com', | 408 | first_name='John', last_name='Does', email='user@email.com', |
613 | 405 | open_id=open_id) | 409 | open_id=open_id) |
615 | 406 | user, created = update_or_create_user_from_oauth( | 410 | user, created = update_or_create_user_from_sso( |
616 | 407 | open_id, update_window=None) | 411 | open_id, update_window=None) |
617 | 408 | self.assertIsNotNone(user) | 412 | self.assertIsNotNone(user) |
618 | 409 | self.assertFalse(created) | 413 | self.assertFalse(created) |
619 | @@ -417,7 +421,7 @@ | |||
620 | 417 | user = self.factory.makeUser( | 421 | user = self.factory.makeUser( |
621 | 418 | first_name='John', last_name='Does', email='user@email.com', | 422 | first_name='John', last_name='Does', email='user@email.com', |
622 | 419 | open_id=open_id) | 423 | open_id=open_id) |
624 | 420 | logged_user, created = update_or_create_user_from_oauth( | 424 | logged_user, created = update_or_create_user_from_sso( |
625 | 421 | open_id, update_window=60 * 60 * 24) | 425 | open_id, update_window=60 * 60 * 24) |
626 | 422 | self.assertIsNotNone(logged_user) | 426 | self.assertIsNotNone(logged_user) |
627 | 423 | self.assertFalse(created) | 427 | self.assertFalse(created) |
628 | @@ -433,7 +437,7 @@ | |||
629 | 433 | self.factory.makeUser( | 437 | self.factory.makeUser( |
630 | 434 | first_name='John', last_name='Does', email='user@email.com', | 438 | first_name='John', last_name='Does', email='user@email.com', |
631 | 435 | open_id=open_id) | 439 | open_id=open_id) |
633 | 436 | user, created = update_or_create_user_from_oauth( | 440 | user, created = update_or_create_user_from_sso( |
634 | 437 | open_id, update_window=None) | 441 | open_id, update_window=None) |
635 | 438 | self.assertIsNotNone(user) | 442 | self.assertIsNotNone(user) |
636 | 439 | self.assertFalse(created) | 443 | self.assertFalse(created) |
637 | @@ -448,7 +452,7 @@ | |||
638 | 448 | self.factory.makeUser( | 452 | self.factory.makeUser( |
639 | 449 | first_name='John', last_name='Does', email='user@email.com', | 453 | first_name='John', last_name='Does', email='user@email.com', |
640 | 450 | open_id=open_id) | 454 | open_id=open_id) |
642 | 451 | user, created = update_or_create_user_from_oauth( | 455 | user, created = update_or_create_user_from_sso( |
643 | 452 | open_id, update_window=None) | 456 | open_id, update_window=None) |
644 | 453 | self.assertIsNotNone(user) | 457 | self.assertIsNotNone(user) |
645 | 454 | self.assertFalse(created) | 458 | self.assertFalse(created) |
646 | @@ -463,9 +467,113 @@ | |||
647 | 463 | self.factory.makeUser( | 467 | self.factory.makeUser( |
648 | 464 | first_name='John', last_name='Does', email='user@email.com', | 468 | first_name='John', last_name='Does', email='user@email.com', |
649 | 465 | open_id=open_id) | 469 | open_id=open_id) |
651 | 466 | user, created = update_or_create_user_from_oauth( | 470 | user, created = update_or_create_user_from_sso( |
652 | 467 | open_id, update_window=None) | 471 | open_id, update_window=None) |
653 | 468 | self.assertIsNotNone(user) | 472 | self.assertIsNotNone(user) |
654 | 469 | self.assertFalse(created) | 473 | self.assertFalse(created) |
655 | 470 | self.assertEqual(user.get_full_name(), 'John Does') | 474 | self.assertEqual(user.get_full_name(), 'John Does') |
656 | 471 | self.assertEqual(user.email, 'user@email.com') | 475 | self.assertEqual(user.email, 'user@email.com') |
657 | 476 | |||
658 | 477 | def test_get_user_from_account_data(self): | ||
659 | 478 | openid = 'oid1234' | ||
660 | 479 | sso_user_data = self.mock_data_for_account(openid=openid) | ||
661 | 480 | |||
662 | 481 | db_user = self.factory.makeUser( | ||
663 | 482 | email=sso_user_data['email'], open_id=openid) | ||
664 | 483 | |||
665 | 484 | user, created = update_or_create_user_from_sso( | ||
666 | 485 | openid, account=sso_user_data) | ||
667 | 486 | self.assertFalse(created) | ||
668 | 487 | self.assertEqual(user, db_user) | ||
669 | 488 | |||
670 | 489 | def test_create_user_from_account_data(self): | ||
671 | 490 | openid = 'oid1234' | ||
672 | 491 | sso_user_data = self.mock_data_for_account(openid=openid) | ||
673 | 492 | |||
674 | 493 | user, created = update_or_create_user_from_sso( | ||
675 | 494 | openid, account=sso_user_data) | ||
676 | 495 | self.assertTrue(created) | ||
677 | 496 | self.assertEqual(user.email, sso_user_data['email']) | ||
678 | 497 | self.assertEqual(user.useropenid_set.first().claimed_id, | ||
679 | 498 | full_claimed_id(openid)) | ||
680 | 499 | |||
681 | 500 | |||
682 | 501 | class VerifyACLTestCase(TestCaseWithFactory, RequestsDoubleTestCaseMixin): | ||
683 | 502 | |||
684 | 503 | def setUp(self): | ||
685 | 504 | super(VerifyACLTestCase, self).setUp() | ||
686 | 505 | self.patch_requests() | ||
687 | 506 | self.sca = WebServices() | ||
688 | 507 | self.sca_double = SCADouble(self.requests_double) | ||
689 | 508 | p = patch('core.utilities.logger') | ||
690 | 509 | self.mock_logger = p.start() | ||
691 | 510 | self.addCleanup(p.stop) | ||
692 | 511 | |||
693 | 512 | def make_request(self): | ||
694 | 513 | request = RequestFactory().post('/') | ||
695 | 514 | authorization = 'Macaroon root="%s", discharge="%s"' % ( | ||
696 | 515 | 'root-macaroon-data', 'discharge-macaroon-data') | ||
697 | 516 | request.META['HTTP_AUTHORIZATION'] = authorization | ||
698 | 517 | return request | ||
699 | 518 | |||
700 | 519 | def assert_verify_acl_response(self, data, **kwargs): | ||
701 | 520 | expected = { | ||
702 | 521 | 'allowed': True, | ||
703 | 522 | 'refresh_required': False, | ||
704 | 523 | 'account': { | ||
705 | 524 | 'openid': 'oid1234', | ||
706 | 525 | 'email': 'user@example.com', | ||
707 | 526 | 'displayname': 'user1234', | ||
708 | 527 | 'verified': True, | ||
709 | 528 | }, | ||
710 | 529 | 'last_auth': '20160515T12:00:00.000', | ||
711 | 530 | 'permissions': ['package_access'], | ||
712 | 531 | } | ||
713 | 532 | expected.update(**kwargs) | ||
714 | 533 | self.assertEqual(data, expected) | ||
715 | 534 | |||
716 | 535 | def test_verify_acl_allowed(self): | ||
717 | 536 | self.sca_double.set_verify_acl_response() | ||
718 | 537 | request = self.make_request() | ||
719 | 538 | data = self.sca.verify_acl(request) | ||
720 | 539 | self.assert_verify_acl_response(data) | ||
721 | 540 | |||
722 | 541 | def test_verify_acl_not_allowed(self): | ||
723 | 542 | self.sca_double.set_verify_acl_response(allowed=False) | ||
724 | 543 | request = self.make_request() | ||
725 | 544 | data = self.sca.verify_acl(request) | ||
726 | 545 | self.assertIsNone(data) | ||
727 | 546 | |||
728 | 547 | def test_verify_acl_failed(self): | ||
729 | 548 | self.requests_double.add_response( | ||
730 | 549 | self.sca_double.verify_acl_url, | ||
731 | 550 | method='POST', body='some error', status_code=500) | ||
732 | 551 | request = self.make_request() | ||
733 | 552 | data = self.sca.verify_acl(request) | ||
734 | 553 | self.assertIsNone(data) | ||
735 | 554 | self.mock_logger.warn.assert_called_once_with( | ||
736 | 555 | 'Failed to verify acl. Response: some error (500)') | ||
737 | 556 | |||
738 | 557 | def test_verify_acl_asks_for_right_permission(self): | ||
739 | 558 | self.sca_double.set_verify_acl_response(permissions=['package_upload']) | ||
740 | 559 | request = self.make_request() | ||
741 | 560 | data = self.sca.verify_acl(request) | ||
742 | 561 | self.assertIsNone(data) | ||
743 | 562 | |||
744 | 563 | def test_verify_acl_returns_account_data(self): | ||
745 | 564 | expected = { | ||
746 | 565 | 'allowed': True, | ||
747 | 566 | 'refresh_required': False, | ||
748 | 567 | 'account': { | ||
749 | 568 | 'openid': 'openid', | ||
750 | 569 | 'displayname': 'username', | ||
751 | 570 | 'email': 'user@example.com', | ||
752 | 571 | 'verified': True, | ||
753 | 572 | }, | ||
754 | 573 | 'last_auth': now().isoformat(), | ||
755 | 574 | 'permissions': ['package_access'], | ||
756 | 575 | } | ||
757 | 576 | self.sca_double.set_verify_acl_response(**expected) | ||
758 | 577 | request = self.make_request() | ||
759 | 578 | data = self.sca.verify_acl(request) | ||
760 | 579 | self.assert_verify_acl_response(data, **expected) | ||
761 | 472 | 580 | ||
762 | === modified file 'src/core/utilities.py' | |||
763 | --- src/core/utilities.py 2014-10-03 17:06:49 +0000 | |||
764 | +++ src/core/utilities.py 2016-05-17 14:32:36 +0000 | |||
765 | @@ -1,9 +1,11 @@ | |||
766 | 1 | import json | ||
767 | 1 | import sys | 2 | import sys |
768 | 2 | import time | 3 | import time |
769 | 3 | 4 | ||
770 | 4 | import logging | 5 | import logging |
771 | 5 | from httplib2 import ServerNotFoundError | 6 | from httplib2 import ServerNotFoundError |
772 | 6 | 7 | ||
773 | 8 | import requests | ||
774 | 7 | from django.conf import settings | 9 | from django.conf import settings |
775 | 8 | from django.contrib.auth.models import User | 10 | from django.contrib.auth.models import User |
776 | 9 | from django.core.cache import cache | 11 | from django.core.cache import cache |
777 | @@ -189,6 +191,28 @@ | |||
778 | 189 | 191 | ||
779 | 190 | return pubs | 192 | return pubs |
780 | 191 | 193 | ||
781 | 194 | def verify_acl(self, request): | ||
782 | 195 | data = { | ||
783 | 196 | 'auth_data': json.dumps({ | ||
784 | 197 | 'http_url': request.build_absolute_uri(), | ||
785 | 198 | 'http_method': request.method, | ||
786 | 199 | 'authorization': request.META.get('HTTP_AUTHORIZATION', None), | ||
787 | 200 | }), | ||
788 | 201 | } | ||
789 | 202 | url = settings.SCA_HOST_URL.strip('/') + '/dev/api/acl/verify/' | ||
790 | 203 | response = requests.post(url, data=json.dumps(data)) | ||
791 | 204 | if response.ok: | ||
792 | 205 | data = response.json() | ||
793 | 206 | if data.get('allowed', False): | ||
794 | 207 | if 'package_access' in data.get('permissions', []): | ||
795 | 208 | # macaroon verified alright | ||
796 | 209 | return data | ||
797 | 210 | # acl verification failed, either request was not allowed, | ||
798 | 211 | # or lacked necessary permissions | ||
799 | 212 | return | ||
800 | 213 | logger.warn('Failed to verify acl. Response: {} ({})'.format( | ||
801 | 214 | response.content, response.status_code)) | ||
802 | 215 | |||
803 | 192 | 216 | ||
804 | 193 | web_services = WebServices() | 217 | web_services = WebServices() |
805 | 194 | 218 | ||
806 | @@ -226,7 +250,7 @@ | |||
807 | 226 | 250 | ||
808 | 227 | 251 | ||
809 | 228 | @transaction.commit_on_success | 252 | @transaction.commit_on_success |
811 | 229 | def _get_or_create_user_from_oauth(consumer_key): | 253 | def _get_or_create_user_from_sso(consumer_key, account=None): |
812 | 230 | claimed_id = full_claimed_id(consumer_key) | 254 | claimed_id = full_claimed_id(consumer_key) |
813 | 231 | created = False | 255 | created = False |
814 | 232 | 256 | ||
815 | @@ -236,8 +260,9 @@ | |||
816 | 236 | user = None | 260 | user = None |
817 | 237 | 261 | ||
818 | 238 | if user is None: | 262 | if user is None: |
821 | 239 | account = web_services.get_data_for_account( | 263 | if account is None: |
822 | 240 | openid_identifier=consumer_key) | 264 | account = web_services.get_data_for_account( |
823 | 265 | openid_identifier=consumer_key) | ||
824 | 241 | account_data = _get_sso_account_data(account) | 266 | account_data = _get_sso_account_data(account) |
825 | 242 | 267 | ||
826 | 243 | if account_data is None: | 268 | if account_data is None: |
827 | @@ -271,8 +296,8 @@ | |||
828 | 271 | return user, created | 296 | return user, created |
829 | 272 | 297 | ||
830 | 273 | 298 | ||
833 | 274 | def update_or_create_user_from_oauth( | 299 | def update_or_create_user_from_sso( |
834 | 275 | consumer_key, update_window=settings.SSO_UPDATE_WINDOW): | 300 | consumer_key, update_window=settings.SSO_UPDATE_WINDOW, account=None): |
835 | 276 | """Update or create user using SSO account data. | 301 | """Update or create user using SSO account data. |
836 | 277 | 302 | ||
837 | 278 | This function tries to retrieve a `User` from the database using | 303 | This function tries to retrieve a `User` from the database using |
838 | @@ -283,15 +308,24 @@ | |||
839 | 283 | given `update_window`. If `update_window` is `None`, SSO data is | 308 | given `update_window`. If `update_window` is `None`, SSO data is |
840 | 284 | always updated. | 309 | always updated. |
841 | 285 | 310 | ||
842 | 311 | If the optional `account` parameter is provided, SSO will not be contacted | ||
843 | 312 | for retrieving the initial user data, but the provided `account` data | ||
844 | 313 | will be used. | ||
845 | 314 | |||
846 | 286 | Returns: | 315 | Returns: |
847 | 287 | A two-element tuple, where the first element is the | 316 | A two-element tuple, where the first element is the |
848 | 288 | created/updated user or None and the last one is a boolean | 317 | created/updated user or None and the last one is a boolean |
849 | 289 | especifying whether the user was created or not. | 318 | especifying whether the user was created or not. |
850 | 290 | 319 | ||
851 | 291 | """ | 320 | """ |
853 | 292 | user, created = _get_or_create_user_from_oauth(consumer_key) | 321 | user, created = _get_or_create_user_from_sso( |
854 | 322 | consumer_key, account=account) | ||
855 | 293 | 323 | ||
856 | 294 | if user is not None: | 324 | if user is not None: |
857 | 325 | # check for update even if account data was provided | ||
858 | 326 | # account data is only used for the initial user creation; | ||
859 | 327 | # after that, wether the login is 'fresh' or not (from rnr perspective) | ||
860 | 328 | # is orthogonal to how the original data was retrieved | ||
861 | 295 | if update_window is None: | 329 | if update_window is None: |
862 | 296 | should_update = True | 330 | should_update = True |
863 | 297 | else: | 331 | else: |
864 | 298 | 332 | ||
865 | === modified file 'src/reviewsapp/api/urls.py' | |||
866 | --- src/reviewsapp/api/urls.py 2014-10-09 19:32:45 +0000 | |||
867 | +++ src/reviewsapp/api/urls.py 2016-05-17 14:32:36 +0000 | |||
868 | @@ -22,7 +22,7 @@ | |||
869 | 22 | ) | 22 | ) |
870 | 23 | 23 | ||
871 | 24 | from piston.resource import Resource | 24 | from piston.resource import Resource |
873 | 25 | from core.api.auth import SSOOAuthAuthentication | 25 | from core.api.auth import MacaroonsAuthentication, SSOOAuthAuthentication |
874 | 26 | from core.api.decorators import ( | 26 | from core.api.decorators import ( |
875 | 27 | gzip_content, | 27 | gzip_content, |
876 | 28 | vary_only_on_accept, | 28 | vary_only_on_accept, |
877 | @@ -44,6 +44,7 @@ | |||
878 | 44 | ) | 44 | ) |
879 | 45 | 45 | ||
880 | 46 | auth = SSOOAuthAuthentication(realm="Ratings and Reviews") | 46 | auth = SSOOAuthAuthentication(realm="Ratings and Reviews") |
881 | 47 | macaroons_auth = MacaroonsAuthentication(realm="Ratings and Reviews") | ||
882 | 47 | 48 | ||
883 | 48 | 49 | ||
884 | 49 | # The server status resource should never be cached. | 50 | # The server status resource should never be cached. |
885 | @@ -99,8 +100,8 @@ | |||
886 | 99 | 100 | ||
887 | 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. |
888 | 101 | # The .__name__ hack is needed to support Django 1.1 | 102 | # The .__name__ hack is needed to support Django 1.1 |
891 | 102 | submit_review_resource = CSRFExemptResource(handler=SubmitReviewHandler, | 103 | submit_review_resource = CSRFExemptResource( |
892 | 103 | authentication=auth) | 104 | handler=SubmitReviewHandler, authentication=(macaroons_auth, auth)) |
893 | 104 | modify_review_resource = CSRFExemptResource(handler=ModifyReviewHandler, | 105 | modify_review_resource = CSRFExemptResource(handler=ModifyReviewHandler, |
894 | 105 | authentication=auth) | 106 | authentication=auth) |
895 | 106 | flag_review_resource = CSRFExemptResource(handler=FlagReviewHandler, | 107 | flag_review_resource = CSRFExemptResource(handler=FlagReviewHandler, |
896 | 107 | 108 | ||
897 | === modified file 'src/reviewsapp/tests/test_handlers.py' | |||
898 | --- src/reviewsapp/tests/test_handlers.py 2016-04-04 04:10:58 +0000 | |||
899 | +++ src/reviewsapp/tests/test_handlers.py 2016-05-17 14:32:36 +0000 | |||
900 | @@ -56,7 +56,11 @@ | |||
901 | 56 | from piston.emitters import Emitter | 56 | from piston.emitters import Emitter |
902 | 57 | from piston.handler import typemapper | 57 | from piston.handler import typemapper |
903 | 58 | 58 | ||
905 | 59 | from core.tests.helpers import assert_no_extra_queries_after | 59 | from core.tests.doubles import SCADouble |
906 | 60 | from core.tests.helpers import ( | ||
907 | 61 | RequestsDoubleTestCaseMixin, | ||
908 | 62 | assert_no_extra_queries_after, | ||
909 | 63 | ) | ||
910 | 60 | from reviewsapp.api.handlers import ( | 64 | from reviewsapp.api.handlers import ( |
911 | 61 | Django13JSONEncoder, | 65 | Django13JSONEncoder, |
912 | 62 | Django13JSONEmitter, | 66 | Django13JSONEmitter, |
913 | @@ -983,7 +987,8 @@ | |||
914 | 983 | self.assertEqual(response['vary'], 'Accept') | 987 | self.assertEqual(response['vary'], 'Accept') |
915 | 984 | 988 | ||
916 | 985 | 989 | ||
918 | 986 | class SubmitReviewHandlerTestCase(TestCaseWithFactory): | 990 | class SubmitReviewHandlerTestCase(TestCaseWithFactory, |
919 | 991 | RequestsDoubleTestCaseMixin): | ||
920 | 987 | 992 | ||
921 | 988 | def setUp(self): | 993 | def setUp(self): |
922 | 989 | super(SubmitReviewHandlerTestCase, self).setUp() | 994 | super(SubmitReviewHandlerTestCase, self).setUp() |
923 | @@ -1000,13 +1005,26 @@ | |||
924 | 1000 | } | 1005 | } |
925 | 1001 | self.factory.makeArch('i386') | 1006 | self.factory.makeArch('i386') |
926 | 1002 | 1007 | ||
928 | 1003 | def _post_new_review(self, data, user=None, verify_result=True): | 1008 | self.patch_requests() |
929 | 1009 | self.sca_double = SCADouble(self.requests_double) | ||
930 | 1010 | p = patch('core.utilities.web_services', new=self.sca_double) | ||
931 | 1011 | p.start() | ||
932 | 1012 | self.addCleanup(p.stop) | ||
933 | 1013 | |||
934 | 1014 | def _post_new_review(self, data, user=None, verify_result=True, | ||
935 | 1015 | auth='oauth'): | ||
936 | 1004 | # Post a review as an authenticated user. | 1016 | # Post a review as an authenticated user. |
937 | 1005 | url = reverse('rnr-api-reviews') | 1017 | url = reverse('rnr-api-reviews') |
938 | 1006 | 1018 | ||
942 | 1007 | if user is None: | 1019 | headers = {} |
943 | 1008 | user = self.factory.makeUser() | 1020 | if auth == 'macaroon': |
944 | 1009 | self.client.login(username=user.username, password='test') | 1021 | authorization = 'Macaroon root="%s", discharge="%s"' % ( |
945 | 1022 | 'root-macaroon-data', 'discharge-macaroon-data') | ||
946 | 1023 | headers['HTTP_AUTHORIZATION'] = authorization | ||
947 | 1024 | else: | ||
948 | 1025 | if user is None: | ||
949 | 1026 | user = self.factory.makeUser() | ||
950 | 1027 | self.client.login(username=user.username, password='test') | ||
951 | 1010 | 1028 | ||
952 | 1011 | # Ensure we don't hit the network for our tests. | 1029 | # Ensure we don't hit the network for our tests. |
953 | 1012 | is_authed_fn = ('core.api.auth.SSOOAuthAuthentication.' | 1030 | is_authed_fn = ('core.api.auth.SSOOAuthAuthentication.' |
954 | @@ -1022,7 +1040,8 @@ | |||
955 | 1022 | with patch(sca_verify_fn) as mock_sca_verify_method: | 1040 | with patch(sca_verify_fn) as mock_sca_verify_method: |
956 | 1023 | mock_sca_verify_method.return_value = verify_result | 1041 | mock_sca_verify_method.return_value = verify_result |
957 | 1024 | response = self.client.post( | 1042 | response = self.client.post( |
959 | 1025 | url, data=data, content_type='application/json') | 1043 | url, data=data, content_type='application/json', |
960 | 1044 | **headers) | ||
961 | 1026 | return response | 1045 | return response |
962 | 1027 | 1046 | ||
963 | 1028 | def test_bogus_data(self): | 1047 | def test_bogus_data(self): |
964 | @@ -1058,6 +1077,18 @@ | |||
965 | 1058 | response_dict = simplejson.loads(response.content) | 1077 | response_dict = simplejson.loads(response.content) |
966 | 1059 | self.assertEqual('inkscape', response_dict['package_name']) | 1078 | self.assertEqual('inkscape', response_dict['package_name']) |
967 | 1060 | 1079 | ||
968 | 1080 | def test_create_using_macaroon_auth(self): | ||
969 | 1081 | self.sca_double.set_verify_acl_response( | ||
970 | 1082 | is_verified=True, openid='oid1234', | ||
971 | 1083 | email='user@example.com', displayname='user1234') | ||
972 | 1084 | response = self._post_new_review(simplejson.dumps(self.required_data), | ||
973 | 1085 | auth='macaroon') | ||
974 | 1086 | self.assertEqual(httplib.OK, response.status_code) | ||
975 | 1087 | self.assertEqual('application/json; charset=utf-8', | ||
976 | 1088 | response['content-type']) | ||
977 | 1089 | response_dict = simplejson.loads(response.content) | ||
978 | 1090 | self.assertEqual('inkscape', response_dict['package_name']) | ||
979 | 1091 | |||
980 | 1061 | def test_creates_repository_and_software_item(self): | 1092 | def test_creates_repository_and_software_item(self): |
981 | 1062 | # Submitting a review for a software item or repository of which | 1093 | # Submitting a review for a software item or repository of which |
982 | 1063 | # we don't yet have a record will create those records (after | 1094 | # we don't yet have a record will create those records (after |
added few comments, otherwise is looking good.