Merge lp:~canonical-isd-hackers/canonical-identity-provider/captcha-tangle into lp:canonical-identity-provider/release
- captcha-tangle
- Merge into trunk
Proposed by
David Owen
Status: | Merged |
---|---|
Merged at revision: | 128 |
Proposed branch: | lp:~canonical-isd-hackers/canonical-identity-provider/captcha-tangle |
Merge into: | lp:canonical-identity-provider/release |
Diff against target: |
491 lines (+136/-87) 8 files modified
identityprovider/models/captcha.py (+34/-19) identityprovider/tests/test_captcha.py (+8/-8) identityprovider/tests/test_forms.py (+2/-1) identityprovider/tests/test_models_captcha.py (+26/-39) identityprovider/tests/test_views_ui.py (+1/-1) identityprovider/views/ui.py (+20/-11) identityprovider/webservice/forms.py (+22/-6) identityprovider/webservice/models.py (+23/-2) |
To merge this branch: | bzr merge lp:~canonical-isd-hackers/canonical-identity-provider/captcha-tangle |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Canonical ISD hackers | Pending | ||
Review via email: mp+47471@code.launchpad.net |
Commit message
Pull a dependency on request objects out of the Captcha model layer.
Description of the change
The Captcha model code had a dependency on fields in the WSGI request object, making the code very difficult to change (for example, to change our API to Piston or similar).
This patch pulls that dependency much close to the view layer.
The web API itself should have remain unchanged.
To post a comment you must log in.
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === modified file 'identityprovider/models/captcha.py' |
2 | --- identityprovider/models/captcha.py 2010-09-24 13:17:26 +0000 |
3 | +++ identityprovider/models/captcha.py 2011-01-25 22:24:43 +0000 |
4 | @@ -11,6 +11,25 @@ |
5 | from django.conf import settings |
6 | |
7 | |
8 | +class CaptchaError(Exception): |
9 | + """All instances of CaptchaError have a traceback member.""" |
10 | + |
11 | + def __init__(self, traceback): |
12 | + self.traceback = traceback |
13 | + |
14 | + |
15 | +class NewCaptchaError(CaptchaError): |
16 | + """The dummy member is a blank Captcha response.""" |
17 | + |
18 | + def __init__(self, traceback, dummy): |
19 | + super(NewCaptchaError, self).__init__(traceback) |
20 | + self.dummy = dummy |
21 | + |
22 | + |
23 | +class VerifyCaptchaError(CaptchaError): |
24 | + pass |
25 | + |
26 | + |
27 | class CaptchaResponse(object): |
28 | """Response returned from _open() in case of error""" |
29 | |
30 | @@ -49,13 +68,13 @@ |
31 | As is verifying received solution: |
32 | |
33 | >>> captcha = Captcha('captcha-id-received-from-client') |
34 | - >>> captcha.verify("this-is-invalid-solution") |
35 | + >>> captcha.verify("this-is-invalid-solution", ip_addr) |
36 | False |
37 | |
38 | Once verified solution is cached, so calling again to .verify() method is |
39 | very cheap (and returns same result): |
40 | |
41 | - >>> captcha.verify("this-is-invalid-solution") |
42 | + >>> captcha.verify("this-is-invalid-solution", ip_addr) |
43 | False |
44 | |
45 | You can also get original response from reCaptcha: |
46 | @@ -68,8 +87,10 @@ |
47 | |
48 | opener = None |
49 | |
50 | - def __init__(self, env, captcha_id, image_url=None, response=None): |
51 | - self.env = env |
52 | + def __init__(self, captcha_id, image_url=None, response=None): |
53 | + assert captcha_id is None or isinstance(captcha_id, basestring) |
54 | + assert image_url is None or isinstance(image_url, basestring) |
55 | + assert response is None or isinstance(response, CaptchaResponse) |
56 | self.captcha_id = captcha_id |
57 | self.image_url = image_url |
58 | self.response = response |
59 | @@ -96,16 +117,13 @@ |
60 | } |
61 | |
62 | @classmethod |
63 | - def new(cls, env): |
64 | + def new(cls): |
65 | cls._setup_opener() |
66 | url = (settings.CAPTCHA_API_URL + |
67 | '/challenge?k=%s' % settings.CAPTCHA_PUBLIC_KEY) |
68 | - response = cls._open(url, env) |
69 | + response = cls._open(url) |
70 | if response.is_error: |
71 | - env['oops-dump'] = True |
72 | - logging.warning(response.traceback) |
73 | - logging.warning("Failed to connect to reCaptcha server") |
74 | - return cls(env, None, None) |
75 | + raise NewCaptchaError(response.traceback, cls(None, None)) |
76 | |
77 | data = response.data() |
78 | m = re.search(r"challenge\s*:\s*'(.+?)'", data, re.M | re.S) |
79 | @@ -114,10 +132,10 @@ |
80 | image_url = settings.CAPTCHA_IMAGE_URL_PATTERN % captcha_id |
81 | else: |
82 | captcha_id, image_url = None, None |
83 | - return cls(env, captcha_id, image_url, response) |
84 | + return cls(captcha_id, image_url, response) |
85 | |
86 | @classmethod |
87 | - def _open(cls, request, env): |
88 | + def _open(cls, request): |
89 | default_timeout = socket.getdefaulttimeout() |
90 | socket.setdefaulttimeout(getattr(settings, 'CAPTCHA_TIMEOUT', 10)) |
91 | |
92 | @@ -142,7 +160,7 @@ |
93 | |
94 | return CaptchaResponse(response.code, response, None) |
95 | |
96 | - def verify(self, captcha_solution): |
97 | + def verify(self, captcha_solution, remote_ip): |
98 | if self._verified is not None: |
99 | return self._verified |
100 | |
101 | @@ -155,12 +173,12 @@ |
102 | |
103 | request_data = urllib.urlencode({ |
104 | 'privatekey': settings.CAPTCHA_PRIVATE_KEY, |
105 | - 'remoteip': self.env['REMOTE_ADDR'], |
106 | + 'remoteip': remote_ip, |
107 | 'challenge': self.captcha_id, |
108 | 'response': captcha_solution, |
109 | }) |
110 | request = urllib2.Request(settings.CAPTCHA_VERIFY_URL, request_data) |
111 | - self.response = self._open(request, self.env) |
112 | + self.response = self._open(request) |
113 | |
114 | if not self.response.is_error: |
115 | response_data = self.response.data() |
116 | @@ -171,8 +189,5 @@ |
117 | self._verfied = False |
118 | else: |
119 | self._verified = False |
120 | - if getattr(self.env, '__setitem__', False): |
121 | - self.env['oops-dump'] = True |
122 | - logging.warning(self.response.traceback) |
123 | - logging.warning("reCaptcha connection error") |
124 | + raise VerifyCaptchaError(self.response.traceback) |
125 | return self._verified |
126 | |
127 | === modified file 'identityprovider/tests/test_captcha.py' |
128 | --- identityprovider/tests/test_captcha.py 2010-11-03 16:06:08 +0000 |
129 | +++ identityprovider/tests/test_captcha.py 2011-01-25 22:24:43 +0000 |
130 | @@ -26,7 +26,7 @@ |
131 | def test_opener_is_created_during_init(self): |
132 | Captcha.opener = None |
133 | |
134 | - captcha = Captcha({}, 'test') |
135 | + captcha = Captcha('test') |
136 | |
137 | self.assertTrue(Captcha.opener is not None) |
138 | |
139 | @@ -41,10 +41,10 @@ |
140 | encoded_solution = captcha_solution.encode('utf-8') |
141 | mock_urlencode.return_value = 'some text' |
142 | |
143 | - captcha = Captcha({'REMOTE_ADDR': 'foobar'}, None) |
144 | + captcha = Captcha(None) |
145 | captcha._open = Mock() |
146 | captcha._open.return_value.is_error = True |
147 | - captcha.verify(captcha_solution) |
148 | + captcha.verify(captcha_solution, 'foobar') |
149 | |
150 | captcha_response = mock_urlencode.call_args[0][0]['response'] |
151 | self.assertEqual(captcha_response, encoded_solution) |
152 | @@ -56,7 +56,7 @@ |
153 | settings.CAPTCHA_USE_PROXY = True |
154 | settings.CAPTCHA_PROXIES = {} |
155 | |
156 | - captcha = Captcha({}, 'test') |
157 | + captcha = Captcha('test') |
158 | |
159 | self.assertTrue(Captcha.opener is not None) |
160 | |
161 | @@ -73,20 +73,20 @@ |
162 | Captcha.opener.open = mock_open |
163 | |
164 | while handled_codes: |
165 | - response = Captcha._open(None, {}) |
166 | + response = Captcha._open(None) |
167 | self.assertTrue(response.is_error) |
168 | |
169 | def test_verify_is_short_circuited_when_disabled_in_settings(self): |
170 | settings.DISABLE_CAPTCHA_VERIFICATION = True |
171 | |
172 | - captcha = Captcha({'REMOTE_ADDR': '127.0.0.1'}, 'test') |
173 | - verify_result = captcha.verify('solution') |
174 | + captcha = Captcha('test') |
175 | + verify_result = captcha.verify('solution', '127.0.0.1') |
176 | |
177 | self.assertTrue(verify_result) |
178 | self.assertTrue(captcha.response is None) |
179 | |
180 | def test_serialize(self): |
181 | - captcha = Captcha({}, 'id', 'image') |
182 | + captcha = Captcha('id', 'image') |
183 | |
184 | self.assertEquals(captcha.serialize(), |
185 | {'captcha_id': 'id', 'image_url': 'image'}) |
186 | |
187 | === modified file 'identityprovider/tests/test_forms.py' |
188 | --- identityprovider/tests/test_forms.py 2010-07-05 16:15:30 +0000 |
189 | +++ identityprovider/tests/test_forms.py 2011-01-25 22:24:43 +0000 |
190 | @@ -84,7 +84,8 @@ |
191 | forms.get_current_browser_request = self.old_get_request |
192 | |
193 | def test_nonascii_password(self): |
194 | - data = {'password': 'Curuzú Cuatiá'} |
195 | + data = {'password': 'Curuzú Cuatiá', |
196 | + 'remote_ip': '127.0.0.1'} |
197 | form = WebserviceCreateAccountForm(data=data) |
198 | self.assertFalse(form.is_valid()) |
199 | self.assertEqual(form.errors['password'][0], |
200 | |
201 | === modified file 'identityprovider/tests/test_models_captcha.py' |
202 | --- identityprovider/tests/test_models_captcha.py 2010-10-25 20:17:15 +0000 |
203 | +++ identityprovider/tests/test_models_captcha.py 2011-01-25 22:24:43 +0000 |
204 | @@ -9,7 +9,12 @@ |
205 | from cStringIO import StringIO |
206 | from django.conf import settings |
207 | |
208 | -from identityprovider.models.captcha import Captcha, CaptchaResponse |
209 | +from identityprovider.models.captcha import ( |
210 | + Captcha, |
211 | + CaptchaResponse, |
212 | + NewCaptchaError, |
213 | + VerifyCaptchaError, |
214 | +) |
215 | |
216 | |
217 | class CaptchaResponseTestCase(unittest.TestCase): |
218 | @@ -33,15 +38,16 @@ |
219 | class CaptchaTestCase(unittest.TestCase): |
220 | |
221 | @patch_object(Captcha, '_open') |
222 | - def test_new_captcha_oops(self, mock_open): |
223 | - c = Captcha.new({}) |
224 | - self.assertTrue(c.env['oops-dump']) |
225 | + def test_new_captcha_error(self, mock_open): |
226 | + self.assertRaises(NewCaptchaError, Captcha.new) |
227 | |
228 | @patch_object(Captcha, '_open') |
229 | def test_new_captcha_no_challenge(self, mock_open): |
230 | - c = Captcha.new({}) |
231 | - self.assertEqual(c.captcha_id, None) |
232 | - self.assertEqual(c.image_url, None) |
233 | + try: |
234 | + Captcha.new() |
235 | + except NewCaptchaError, e: |
236 | + self.assertEqual(e.dummy.captcha_id, None) |
237 | + self.assertEqual(e.dummy.image_url, None) |
238 | |
239 | def test_captcha_open(self): |
240 | class MockOpener(object): |
241 | @@ -50,7 +56,7 @@ |
242 | old_opener = Captcha.opener |
243 | Captcha.opener = MockOpener() |
244 | |
245 | - self.assertRaises(urllib2.URLError, Captcha.new, {}) |
246 | + self.assertRaises(urllib2.URLError, Captcha.new) |
247 | |
248 | Captcha.opener = old_opener |
249 | |
250 | @@ -66,30 +72,30 @@ |
251 | |
252 | @patch_object(Captcha, '_open') |
253 | def test_verify_already_verified(self, mock_open): |
254 | - c = Captcha.new({}) |
255 | + c = Captcha(None) |
256 | c._verified = True |
257 | - r = c.verify(None) |
258 | + r = c.verify(None, None) |
259 | self.assertTrue(r) |
260 | |
261 | @patch_object(Captcha, '_open') |
262 | def test_verify_no_verification(self, mock_open): |
263 | settings.DISABLE_CAPTCHA_VERIFICATION = True |
264 | |
265 | - c = Captcha.new({}) |
266 | - r = c.verify(None) |
267 | + c = Captcha(None) |
268 | + r = c.verify(None, None) |
269 | self.assertTrue(r) |
270 | |
271 | @patch_object(Captcha, '_open') |
272 | def test_verify_response_ok(self, mock_open): |
273 | @classmethod |
274 | - def mock_open(cls, request, env): |
275 | + def mock_open(cls, request): |
276 | r = CaptchaResponse(200, StringIO('true\nok')) |
277 | return r |
278 | old_open = Captcha._open |
279 | Captcha._open = mock_open |
280 | |
281 | - c = Captcha.new({'REMOTE_ADDR': 'localhost'}) |
282 | - r = c.verify(None) |
283 | + c = Captcha.new() |
284 | + r = c.verify(None, "localhost") |
285 | |
286 | self.assertTrue(r) |
287 | self.assertEqual(c.message, 'ok') |
288 | @@ -98,34 +104,15 @@ |
289 | |
290 | @patch_object(Captcha, '_open') |
291 | def test_verify_no_captcha_id(self, mock_open): |
292 | - c = Captcha.new({'REMOTE_ADDR': 'localhost'}) |
293 | - r = c.verify(None) |
294 | + c = Captcha(None) |
295 | + r = c.verify(None, 'localhost') |
296 | |
297 | self.assertFalse(r) |
298 | self.assertEqual(c.message, 'no-challenge') |
299 | |
300 | @patch_object(Captcha, '_open') |
301 | def test_verify_error(self, mock_open): |
302 | - class MockEnv(dict): |
303 | - def __getattribute__(self, attr): |
304 | - if attr == '__setitem__': |
305 | - raise AttributeError() |
306 | - else: |
307 | - return super(MockEnv, self).__getattribute__(attr) |
308 | - |
309 | - env = MockEnv({'REMOTE_ADDR': 'localhost'}) |
310 | - c = Captcha(env, 'challenge-id') |
311 | - r = c.verify(None) |
312 | - |
313 | - self.assertFalse(r) |
314 | - self.assertFalse('oops-dump' in c.env) |
315 | - |
316 | - @patch_object(Captcha, '_open') |
317 | - def test_verify_error_oops(self, mock_open): |
318 | - c = Captcha.new({'REMOTE_ADDR': 'localhost'}) |
319 | + c = Captcha(None) |
320 | c.captcha_id = 'challenge-id' |
321 | - r = c.verify(None) |
322 | - |
323 | - self.assertFalse(r) |
324 | - self.assertTrue(c.env['oops-dump']) |
325 | - |
326 | + |
327 | + self.assertRaises(VerifyCaptchaError, c.verify, None, 'localhost') |
328 | |
329 | === modified file 'identityprovider/tests/test_views_ui.py' |
330 | --- identityprovider/tests/test_views_ui.py 2010-11-11 13:07:16 +0000 |
331 | +++ identityprovider/tests/test_views_ui.py 2011-01-25 22:24:43 +0000 |
332 | @@ -565,7 +565,7 @@ |
333 | def __init__(self, *args): |
334 | pass |
335 | |
336 | - def verify(self, solution): |
337 | + def verify(self, solution, ip_addr): |
338 | self.message = 'no-challenge' |
339 | return False |
340 | |
341 | |
342 | === modified file 'identityprovider/views/ui.py' |
343 | --- identityprovider/views/ui.py 2010-11-16 19:36:42 +0000 |
344 | +++ identityprovider/views/ui.py 2011-01-25 22:24:43 +0000 |
345 | @@ -29,7 +29,7 @@ |
346 | TokenForm) |
347 | from identityprovider.models import (Account, AccountPassword, AuthToken, |
348 | AuthTokenFactory, EmailAddress, get_type_of_token, verify_token_string) |
349 | -from identityprovider.models.captcha import Captcha |
350 | +from identityprovider.models.captcha import Captcha, VerifyCaptchaError |
351 | from identityprovider.models.const import (AccountStatus, EmailStatus, |
352 | LoginTokenType) |
353 | from identityprovider.models.openidmodels import OpenIDRPConfig |
354 | @@ -421,17 +421,26 @@ |
355 | |
356 | |
357 | def _verify_captcha_response(template, request, form): |
358 | - captcha = Captcha(request.environ, |
359 | - request.POST.get('recaptcha_challenge_field')) |
360 | + captcha = Captcha(request.POST.get('recaptcha_challenge_field')) |
361 | captcha_solution = request.POST.get('recaptcha_response_field') |
362 | - verified = captcha.verify(captcha_solution) |
363 | - if not verified: |
364 | - return render_to_response(template, |
365 | - RequestContext(request, {'form': form, |
366 | - 'CAPTCHA_PUBLIC_KEY': settings.CAPTCHA_PUBLIC_KEY, |
367 | - 'captcha_error': ('&error=%s' % captcha.message), |
368 | - })) |
369 | - return None |
370 | + ip_addr = request.environ["REMOTE_ADDR"] |
371 | + try: |
372 | + verified = captcha.verify(captcha_solution, ip_addr) |
373 | + if verified: |
374 | + return None |
375 | + except VerifyCaptchaError, e: |
376 | + if getattr(request.environ, '__setitem__', False): |
377 | + request.environ['oops-dump'] = True |
378 | + logging.warning(e.traceback) |
379 | + logging.warning("reCaptcha connection error") |
380 | + |
381 | + # not verified |
382 | + return render_to_response( |
383 | + template, |
384 | + RequestContext(request, { |
385 | + 'form': form, |
386 | + 'CAPTCHA_PUBLIC_KEY': settings.CAPTCHA_PUBLIC_KEY, |
387 | + 'captcha_error': ('&error=%s' % captcha.message)})) |
388 | |
389 | |
390 | # {{workflow}} |
391 | |
392 | === modified file 'identityprovider/webservice/forms.py' |
393 | --- identityprovider/webservice/forms.py 2010-08-23 14:51:12 +0000 |
394 | +++ identityprovider/webservice/forms.py 2011-01-25 22:24:43 +0000 |
395 | @@ -9,7 +9,7 @@ |
396 | from lazr.restful.utils import get_current_browser_request |
397 | |
398 | from identityprovider.utils import password_policy_compliant |
399 | -from identityprovider.models.captcha import Captcha |
400 | +from identityprovider.models.captcha import Captcha, VerifyCaptchaError |
401 | |
402 | |
403 | PASSWORD_POLICY_ERROR = _("Password must be at least " |
404 | @@ -22,6 +22,7 @@ |
405 | password = fields.CharField(max_length=256) |
406 | captcha_id = fields.CharField(max_length=1024) |
407 | captcha_solution = fields.CharField(max_length=256) |
408 | + remote_ip = fields.CharField(max_length=256) |
409 | |
410 | def clean_password(self): |
411 | if 'password' in self.cleaned_data: |
412 | @@ -40,9 +41,24 @@ |
413 | captcha_id = cleaned_data.get('captcha_id') |
414 | captcha_solution = cleaned_data.get('captcha_solution') |
415 | |
416 | + # The remote IP address is absolutely required, and comes from |
417 | + # SSO itself, not from the client. If it's missing, it's a |
418 | + # programming error, and should not be returned to the client |
419 | + # as a validation error. So, we use a normal key lookup here. |
420 | + remote_ip = cleaned_data['remote_ip'] |
421 | + |
422 | request = get_current_browser_request() |
423 | - captcha = Captcha(request.environment, captcha_id) |
424 | - if not captcha.verify(captcha_solution): |
425 | - raise forms.ValidationError(_("Wrong captcha solution.")) |
426 | - |
427 | - return cleaned_data |
428 | + |
429 | + captcha = Captcha(captcha_id) |
430 | + |
431 | + try: |
432 | + if captcha.verify(captcha_solution, remote_ip): |
433 | + return cleaned_data |
434 | + except VerifyCaptchaError, e: |
435 | + if getattr(request.environment, '__setitem__', False): |
436 | + request.environment['oops-dump'] = True |
437 | + logging.warning(e.traceback) |
438 | + logging.warning("reCaptcha connection error") |
439 | + |
440 | + # not verified |
441 | + raise forms.ValidationError(_("Wrong captcha solution.")) |
442 | |
443 | === modified file 'identityprovider/webservice/models.py' |
444 | --- identityprovider/webservice/models.py 2011-01-24 18:28:24 +0000 |
445 | +++ identityprovider/webservice/models.py 2011-01-25 22:24:43 +0000 |
446 | @@ -26,7 +26,10 @@ |
447 | IAuthenticationSet, IRegistrationSet, ICaptchaSet) |
448 | from identityprovider.webservice.forms import ( |
449 | WebserviceCreateAccountForm, PASSWORD_POLICY_ERROR) |
450 | -from identityprovider.models.captcha import Captcha as CaptchaModel |
451 | +from identityprovider.models.captcha import ( |
452 | + Captcha as CaptchaModel, |
453 | + NewCaptchaError, |
454 | +) |
455 | from identityprovider.views.server import get_team_memberships |
456 | from identityprovider.utils import (CannotResetPasswordException, |
457 | PersonAndAccountNotFoundException, get_person_and_account_by_email) |
458 | @@ -225,6 +228,16 @@ |
459 | class RegistrationSet(make_set('BaseRegistrationSet', 'registration', |
460 | IRegistrationSet, Account, 'id')): |
461 | def register(self, **kwargs): |
462 | + request = get_current_browser_request() |
463 | + # TODO: request doesn't exist in a test, unless we go through |
464 | + # the test client browser. |
465 | + if request is None: |
466 | + kwargs['remote_ip'] = '127.0.0.1' |
467 | + else: |
468 | + if hasattr(request, 'environment'): |
469 | + kwargs['remote_ip'] = request.environment['REMOTE_ADDR'] |
470 | + else: |
471 | + kwargs['remote_ip'] = request.environ['REMOTE_ADDR'] |
472 | form = WebserviceCreateAccountForm(kwargs) |
473 | if not form.is_valid(): |
474 | errors = dict((k, map(unicode, v)) |
475 | @@ -341,7 +354,15 @@ |
476 | Captcha, 'id')): |
477 | def new(self): |
478 | request = get_current_browser_request() |
479 | - return CaptchaModel.new(request.environment).serialize() |
480 | + try: |
481 | + return CaptchaModel.new().serialize() |
482 | + except NewCaptchaError, e: |
483 | + request.environment['oops-dump'] = True |
484 | + logging.warning(e.traceback) |
485 | + logging.warning("Failed to connect to reCaptcha server") |
486 | + # TODO: Some better return here? |
487 | + return e.dummy |
488 | + |
489 | |
490 | |
491 | class SSOWebServiceRootResource(RootResource): |