Merge lp:~canonical-isd-hackers/canonical-identity-provider/captcha-tangle into lp:canonical-identity-provider/release

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
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):