Merge ~maxiberta/canonical-identity-provider:forgot-password-captcha into canonical-identity-provider:master
- Git
- lp:~maxiberta/canonical-identity-provider
- forgot-password-captcha
- Merge into master
Status: | Merged |
---|---|
Approved by: | Maximiliano Bertacchini |
Approved revision: | 9d106744d43ab2df3c71e5357e660114c1e93cfa |
Merge reported by: | Otto Co-Pilot |
Merged at revision: | not available |
Proposed branch: | ~maxiberta/canonical-identity-provider:forgot-password-captcha |
Merge into: | canonical-identity-provider:master |
Diff against target: |
611 lines (+291/-68) 11 files modified
src/identityprovider/models/captcha.py (+8/-7) src/identityprovider/tests/test_models_captcha.py (+10/-0) src/webui/templates/account/confirm_new_email.html (+1/-8) src/webui/templates/registration/forgot_password.html (+3/-0) src/webui/templates/widgets/recaptcha-v2.html (+7/-0) src/webui/tests/test_loginservice.py (+32/-0) src/webui/tests/test_views_registration.py (+150/-25) src/webui/tests/test_views_ui.py (+26/-0) src/webui/views/registration.py (+38/-9) src/webui/views/ui.py (+13/-16) src/webui/views/utils.py (+3/-3) |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Hasan Ammar (community) | Approve | ||
Guillermo Gonzalez | Approve | ||
Review via email: mp+406833@code.launchpad.net |
Commit message
Add captcha to the forgotten password view
And a couple of related cleanups
Description of the change
Changes are neatly split in separate commits, including:
- Unify rendering of captcha error message in a single template, with more consistent vanilla style.
- Drop unused code in webui.views.
- Drop duplicate assertions in the old, captcha-agnostic forgotten password tests.
- Refactor/cleanup the captcha logic in the email confirmation view to make it similar to the forgotten password.
As a result, both the email confirmation and the forgotten password views now require Captcha; both behind the CAPTCHA feature flag. We could use separate feature flags for each view if needed. Also, I think the captcha logic should probably go into form validation to make it more djangoish. But otoh the current implementation at the view level is quite compact and has less moving parts. That said, we could consider just dropping our custom recaptcha implementation (hi NIH!) and use e.g. https:/
Maximiliano Bertacchini (maxiberta) wrote : | # |
Maximiliano Bertacchini (maxiberta) wrote : | # |
Updated comments for reviewers.
Hasan Ammar (hasanammar) wrote : | # |
Looks good, but see an inline suggestion and a comment. Thanks.
Maximiliano Bertacchini (maxiberta) wrote : | # |
Thanks! Will push a improvements shortly.
Hasan Ammar (hasanammar) wrote : | # |
Looks great, thanks for the improvements!
Preview Diff
1 | diff --git a/src/identityprovider/models/captcha.py b/src/identityprovider/models/captcha.py |
2 | index 0e5f68f..ef7a234 100644 |
3 | --- a/src/identityprovider/models/captcha.py |
4 | +++ b/src/identityprovider/models/captcha.py |
5 | @@ -107,11 +107,12 @@ class CaptchaBase(object): |
6 | |
7 | return CaptchaResponse(response.code, response, None) |
8 | |
9 | - def check_whitelist(self, email): |
10 | - for pattern in settings.EMAIL_WHITELIST_REGEXP_LIST: |
11 | - if re.match(pattern, email): |
12 | - return True |
13 | - return False |
14 | + @staticmethod |
15 | + def check_whitelist(email): |
16 | + return any( |
17 | + re.match(pattern, email) |
18 | + for pattern in settings.EMAIL_WHITELIST_REGEXP_LIST |
19 | + ) |
20 | |
21 | |
22 | class CaptchaV2(CaptchaBase): |
23 | @@ -123,11 +124,11 @@ class CaptchaV2(CaptchaBase): |
24 | with maybe_contextmanager(timer, 'captcha-new', None): |
25 | super(CaptchaV2, self).__init__() |
26 | |
27 | - def verify(self, captcha_solution, remote_ip, email, timer=None): |
28 | + def verify(self, captcha_solution, remote_ip, email=None, timer=None): |
29 | if self._verified is not None: |
30 | return self._verified |
31 | |
32 | - if self.check_whitelist(email): |
33 | + if email and self.check_whitelist(email): |
34 | return True |
35 | |
36 | if not settings.CAPTCHA_PRIVATE_KEY: |
37 | diff --git a/src/identityprovider/tests/test_models_captcha.py b/src/identityprovider/tests/test_models_captcha.py |
38 | index 2730468..f8fe023 100644 |
39 | --- a/src/identityprovider/tests/test_models_captcha.py |
40 | +++ b/src/identityprovider/tests/test_models_captcha.py |
41 | @@ -95,6 +95,16 @@ class CaptchaV2VerifyTestCase(CaptchaBaseTestCase): |
42 | |
43 | captcha.check_whitelist.assert_called_with('foo') |
44 | |
45 | + def test_verify_with_no_email(self): |
46 | + captcha = CaptchaV2(None) |
47 | + captcha.check_whitelist = Mock(return_value=False) |
48 | + |
49 | + result = captcha.verify(None, 'localhost') |
50 | + self.assertFalse(result) |
51 | + self.assertTrue(self.mock_captcha_open.called) |
52 | + |
53 | + captcha.check_whitelist.assert_not_called() |
54 | + |
55 | def test_check_whitelist_match(self): |
56 | captcha = CaptchaV2(None) |
57 | |
58 | diff --git a/src/webui/templates/account/confirm_new_email.html b/src/webui/templates/account/confirm_new_email.html |
59 | index b71e8c6..79552b5 100644 |
60 | --- a/src/webui/templates/account/confirm_new_email.html |
61 | +++ b/src/webui/templates/account/confirm_new_email.html |
62 | @@ -20,14 +20,7 @@ GNU Affero General Public License version 3 (see the file LICENSE). |
63 | <form action="" method="post"> |
64 | {% csrf_token %} |
65 | {% if captcha_required %} |
66 | - <p class="captcha" id="captcha"> |
67 | - {% if captcha_error_message %} |
68 | - <span class="error"> |
69 | - {{ captcha_error_message }} |
70 | - </span> |
71 | - {% endif %} |
72 | - {% include "widgets/recaptcha-v2.html" %} |
73 | - </p> |
74 | + {% include "widgets/recaptcha-v2.html" %} |
75 | {% endif %} |
76 | <input type="hidden" name="post" value="yes" /> |
77 | <button type="submit" name="continue" class="p-button--positive" data-qa-id="confirm_email_validation">{% trans "Yes, I'm sure" %}</button> |
78 | diff --git a/src/webui/templates/registration/forgot_password.html b/src/webui/templates/registration/forgot_password.html |
79 | index 6c5c4d2..53d7128 100644 |
80 | --- a/src/webui/templates/registration/forgot_password.html |
81 | +++ b/src/webui/templates/registration/forgot_password.html |
82 | @@ -43,6 +43,9 @@ GNU Affero General Public License version 3 (see the file LICENSE). |
83 | </p> |
84 | {% endif %} |
85 | </div> |
86 | + {% if captcha_required %} |
87 | + {% include "widgets/recaptcha-v2.html" %} |
88 | + {% endif %} |
89 | <button type="submit" name="continue" class="p-button--positive" data-qa-id="send_password_reset_token">{% trans "Continue" %}</button> |
90 | </form> |
91 | </div> |
92 | diff --git a/src/webui/templates/widgets/recaptcha-v2.html b/src/webui/templates/widgets/recaptcha-v2.html |
93 | index 67bb710..0daa4da 100644 |
94 | --- a/src/webui/templates/widgets/recaptcha-v2.html |
95 | +++ b/src/webui/templates/widgets/recaptcha-v2.html |
96 | @@ -10,3 +10,10 @@ GNU Affero General Public License version 3 (see the file LICENSE). |
97 | <script src="https://www.google.com/recaptcha/api.js" async defer></script> |
98 | <div id="g-recaptcha" class="g-recaptcha" data-sitekey="{{ CAPTCHA_PUBLIC_KEY }}" data-theme="light"></div> |
99 | </div> |
100 | +<p class="captcha" id="captcha"> |
101 | + {% if captcha_error_message %} |
102 | + <span class="p-form-validation__message"> |
103 | + <strong>Error:</strong> {{ captcha_error_message }} |
104 | + </span> |
105 | + {% endif %} |
106 | +</p> |
107 | diff --git a/src/webui/tests/test_loginservice.py b/src/webui/tests/test_loginservice.py |
108 | index 14a3a15..6ea96a3 100644 |
109 | --- a/src/webui/tests/test_loginservice.py |
110 | +++ b/src/webui/tests/test_loginservice.py |
111 | @@ -10,6 +10,7 @@ from gargoyle.testutils import switches |
112 | from identityprovider.fields import FIELD_REQUIRED, INVALID_EMAIL |
113 | from identityprovider.login import LOGIN_INCORRECT |
114 | from identityprovider.models.authtoken import AuthToken |
115 | +from identityprovider.models.captcha import CaptchaV2 |
116 | from identityprovider.tests.utils import SSOBaseTestCase |
117 | |
118 | |
119 | @@ -129,3 +130,34 @@ class ForgottenPasswordTest(SSOBaseTestCase): |
120 | self.assertTemplateUsed(response, 'registration/email_sent.html') |
121 | |
122 | AuthToken.objects.all().delete() |
123 | + |
124 | + |
125 | +@switches(CAPTCHA=True) |
126 | +class ForgottenPasswordWithCaptchaTest(SSOBaseTestCase): |
127 | + |
128 | + fixtures = ["test"] |
129 | + |
130 | + def test_invalid_captcha(self): |
131 | + self.patch(CaptchaV2, 'verify', return_value=False) |
132 | + query = {'email': 'test@canonical.com', 'g-recaptcha-response': '0'} |
133 | + self.preseed_cookie_check() |
134 | + response = self.client.post('/+forgot_password', query) |
135 | + self.assertEqual(response.status_code, 200) |
136 | + self.assertEqual(len(mail.outbox), 0) |
137 | + self.assertTemplateUsed(response, 'registration/forgot_password.html') |
138 | + |
139 | + def test_success(self): |
140 | + self.patch(CaptchaV2, 'verify', return_value=True) |
141 | + query = {'email': 'test@canonical.com', 'g-recaptcha-response': '0'} |
142 | + self.preseed_cookie_check() |
143 | + response = self.client.post('/+forgot_password', query) |
144 | + self.assertContains(response, "Reset password") |
145 | + self.assertContains(response, "test@canonical.com") |
146 | + self.assertEqual(len(mail.outbox), 1) |
147 | + self.assertEqual( |
148 | + mail.outbox[0].subject, |
149 | + u"{}: Forgotten Password".format(settings.NOREPLY_FROM_NAME), |
150 | + ) |
151 | + mail.outbox = [] |
152 | + self.assertTemplateUsed(response, 'registration/email_sent.html') |
153 | + AuthToken.objects.all().delete() |
154 | diff --git a/src/webui/tests/test_views_registration.py b/src/webui/tests/test_views_registration.py |
155 | index df5ef51..92168a5 100644 |
156 | --- a/src/webui/tests/test_views_registration.py |
157 | +++ b/src/webui/tests/test_views_registration.py |
158 | @@ -13,7 +13,7 @@ from django.test.utils import override_settings |
159 | from django.urls import reverse |
160 | |
161 | from gargoyle.testutils import switches |
162 | -from mock import Mock, call |
163 | +from mock import ANY, Mock, call |
164 | from oath import hotp |
165 | from ssoclient.v2 import errors as api_errors |
166 | |
167 | @@ -27,6 +27,7 @@ from identityprovider.forms import ( |
168 | TwoFactorSyncForm, |
169 | ) |
170 | from identityprovider.models import Account |
171 | +from identityprovider.models.captcha import CaptchaV2 |
172 | from identityprovider.models.twofactor import ( |
173 | TWOFACTOR_LOGIN, |
174 | TWOFACTOR_SYNC_ERROR, |
175 | @@ -485,6 +486,7 @@ class RegisterMessagesTestCase(SSOBaseTestCase, RegisterTestMixin): |
176 | self.assertIsNone(Account.objects.get_by_email('foo@foo.com')) |
177 | |
178 | |
179 | +@switches(CAPTCHA=False) |
180 | class PasswordResetRequestTestCase(SSOBaseTestCase): |
181 | |
182 | url_name = 'forgot_password' |
183 | @@ -516,11 +518,11 @@ class PasswordResetRequestTestCase(SSOBaseTestCase): |
184 | expected = call(name, key=key, rpconfig=rpconfig) |
185 | self.assertEqual(expected, actual) |
186 | |
187 | - def assert_form_displayed(self, response, **kwargs): |
188 | + def assert_form_displayed(self, response, errors=None): |
189 | self.assertTemplateUsed(response, 'registration/forgot_password.html') |
190 | form = response.context_data['form'] |
191 | - for field, value in kwargs.items(): |
192 | - self.assertIn(unicode(value), form.errors[field][0]) |
193 | + for field, value in (errors or {}).items(): |
194 | + self.assertEqual(value, form.errors[field]) |
195 | |
196 | def do_request(self, method, token=None, data=None, **kwargs): |
197 | # Before running any test, prepare the test cookie. |
198 | @@ -549,7 +551,6 @@ class PasswordResetRequestTestCase(SSOBaseTestCase): |
199 | self.assertEqual(ctx['rpconfig'], None) |
200 | self.assert_form_displayed(response) |
201 | self.assert_stat_calls(['requested']) |
202 | - self.assertTemplateUsed(response, 'registration/forgot_password.html') |
203 | |
204 | def test_get_with_email(self): |
205 | response = self.get(data=dict(email='test@test.com')) |
206 | @@ -579,17 +580,15 @@ class PasswordResetRequestTestCase(SSOBaseTestCase): |
207 | |
208 | def test_post_required_fields(self): |
209 | response = self.post() |
210 | - self.assert_form_displayed(response, email=FIELD_REQUIRED) |
211 | + self.assert_form_displayed( |
212 | + response, errors={'email': [FIELD_REQUIRED]} |
213 | + ) |
214 | self.assert_stat_calls(['error.form']) |
215 | - self.assertTemplateUsed(response, 'registration/forgot_password.html') |
216 | |
217 | def test_post_invalid_data(self): |
218 | response = self.post(data=dict(email="111")) |
219 | - |
220 | - self.assert_form_displayed(response, email=INVALID_EMAIL) |
221 | - self.assert_form_displayed(response) |
222 | + self.assert_form_displayed(response, errors={'email': [INVALID_EMAIL]}) |
223 | self.assert_stat_calls(['error.form']) |
224 | - self.assertTemplateUsed(response, 'registration/forgot_password.html') |
225 | |
226 | def test_post_invalid_data_with_token(self): |
227 | token = '12345678' * 2 |
228 | @@ -600,7 +599,6 @@ class PasswordResetRequestTestCase(SSOBaseTestCase): |
229 | |
230 | self.assert_form_displayed(response) |
231 | self.assert_stat_calls(['error.form']) |
232 | - self.assertTemplateUsed(response, 'registration/forgot_password.html') |
233 | |
234 | def test_post_success(self): |
235 | response = self.post(data=self.data) |
236 | @@ -625,18 +623,6 @@ class PasswordResetRequestTestCase(SSOBaseTestCase): |
237 | self.client.session['token_email'], self.data['email']) |
238 | self.assertTemplateUsed(response, 'registration/email_sent.html') |
239 | |
240 | - def test_post_with_unknown_email(self): |
241 | - response = self.post(data=self.data) |
242 | - |
243 | - self.mock_api_request_password_reset.assert_called_once_with( |
244 | - email=self.data['email'], token=None) |
245 | - self.assertEqual(response.status_code, 200) |
246 | - self.assertEqual( |
247 | - self.client.session['token_email'], self.data['email']) |
248 | - self.assertEqual(response.context_data['email_heading'], |
249 | - 'Reset password') |
250 | - self.assertTemplateUsed(response, 'registration/email_sent.html') |
251 | - |
252 | def test_post_email_invalidated(self): |
253 | exc = api_errors.EmailInvalidated(Mock()) |
254 | self.mock_api_request_password_reset.side_effect = exc |
255 | @@ -682,7 +668,10 @@ class PasswordResetRequestTestCase(SSOBaseTestCase): |
256 | self.mock_api_request_password_reset.side_effect = exc |
257 | response = self.post(data=self.data) |
258 | self.assertEqual(response.status_code, 200) |
259 | - self.assert_form_displayed(response, __all__=ERROR_TOO_MANY_REQUESTS) |
260 | + self.assert_form_displayed( |
261 | + response, |
262 | + errors={'__all__': [ERROR_TOO_MANY_REQUESTS]}, |
263 | + ) |
264 | self.assert_stat_calls(['error.too_many_requests']) |
265 | |
266 | def test_post_disabled_when_readonly(self): |
267 | @@ -696,6 +685,142 @@ class PasswordResetRequestTestCase(SSOBaseTestCase): |
268 | self.assertEqual(response.status_code, 405) |
269 | |
270 | |
271 | +@switches(CAPTCHA=True) |
272 | +@override_settings(CAPTCHA_PUBLIC_KEY='42') |
273 | +class PasswordResetRequestWithCaptchaTestCase(SSOBaseTestCase): |
274 | + |
275 | + url_name = 'forgot_password' |
276 | + |
277 | + def setUp(self): |
278 | + super(PasswordResetRequestWithCaptchaTestCase, self).setUp() |
279 | + self.data = {'email': 'test@test.com', 'g-recaptcha-response': 'foo'} |
280 | + self.mock_get_api_client = self.patch( |
281 | + 'webui.views.registration.get_api_client' |
282 | + ) |
283 | + self.mock_api_request_password_reset = ( |
284 | + self.mock_get_api_client.return_value.request_password_reset |
285 | + ) |
286 | + self.mock_captcha_verify = self.patch(CaptchaV2, 'verify') |
287 | + |
288 | + def assert_form_displayed(self, response, errors=None): |
289 | + self.assertEqual(response.status_code, 200) |
290 | + self.assertTemplateUsed(response, 'registration/forgot_password.html') |
291 | + self.assertTemplateUsed(response, 'widgets/recaptcha-v2.html') |
292 | + self.assertTrue(response.context_data['captcha_required']) |
293 | + self.assertEqual('42', response.context_data['CAPTCHA_PUBLIC_KEY']) |
294 | + form = response.context_data['form'] |
295 | + for field, value in (errors or {}).items(): |
296 | + if field == 'captcha': |
297 | + self.assertIn(value, response.content) |
298 | + else: |
299 | + self.assertEqual(value, form.errors[field]) |
300 | + |
301 | + def do_request(self, method, token=None, data=None, **kwargs): |
302 | + # Before running any test, prepare the test cookie. |
303 | + self.preseed_cookie_check() |
304 | + url = reverse(self.url_name, kwargs={'token': token} if token else {}) |
305 | + return getattr(self.client, method.lower())( |
306 | + url, data=data or {}, **kwargs |
307 | + ) |
308 | + |
309 | + def get(self, **kwargs): |
310 | + return self.do_request(method='GET', **kwargs) |
311 | + |
312 | + def post(self, **kwargs): |
313 | + return self.do_request(method='POST', **kwargs) |
314 | + |
315 | + def test_get(self): |
316 | + response = self.get() |
317 | + self.assert_form_displayed(response) |
318 | + |
319 | + def test_post_required_fields(self): |
320 | + response = self.post() |
321 | + self.assert_form_displayed( |
322 | + response, |
323 | + errors={ |
324 | + 'email': [FIELD_REQUIRED], |
325 | + 'captcha': 'A captcha challenge is required', |
326 | + }, |
327 | + ) |
328 | + |
329 | + def test_post_invalid_email(self): |
330 | + response = self.post(data={'email': '111'}) |
331 | + self.assert_form_displayed( |
332 | + response, |
333 | + errors={ |
334 | + 'email': [INVALID_EMAIL], |
335 | + 'captcha': 'A captcha challenge is required', |
336 | + }, |
337 | + ) |
338 | + |
339 | + def test_post_invalid_captcha(self): |
340 | + self.mock_captcha_verify.return_value = False |
341 | + response = self.post(data=self.data) |
342 | + self.assert_form_displayed( |
343 | + response, |
344 | + errors={'captcha': 'A captcha challenge is required'}, |
345 | + ) |
346 | + |
347 | + def test_post_success(self): |
348 | + self.mock_captcha_verify.return_value = True |
349 | + response = self.post(data=self.data) |
350 | + self.mock_api_request_password_reset.assert_called_once_with( |
351 | + email=self.data['email'], token=None |
352 | + ) |
353 | + self.mock_captcha_verify.assert_called_once_with( |
354 | + 'foo', '127.0.0.1', timer=ANY |
355 | + ) |
356 | + self.assertEqual( |
357 | + self.client.session['token_email'], self.data['email'] |
358 | + ) |
359 | + self.assertTemplateUsed(response, 'registration/email_sent.html') |
360 | + |
361 | + def test_post_email_invalidated(self): |
362 | + self.mock_captcha_verify.return_value = True |
363 | + exc = api_errors.EmailInvalidated(Mock()) |
364 | + self.mock_api_request_password_reset.side_effect = exc |
365 | + response = self.post(data=self.data) |
366 | + self.assert_form_displayed(response) |
367 | + |
368 | + def test_post_account_suspended(self): |
369 | + self.mock_captcha_verify.return_value = True |
370 | + exc = api_errors.AccountSuspended(Mock()) |
371 | + self.mock_api_request_password_reset.side_effect = exc |
372 | + response = self.post(data=self.data) |
373 | + self.assert_form_displayed(response) |
374 | + |
375 | + def test_post_account_deactivated(self): |
376 | + self.mock_captcha_verify.return_value = True |
377 | + exc = api_errors.AccountDeactivated(Mock()) |
378 | + self.mock_api_request_password_reset.side_effect = exc |
379 | + response = self.post(data=self.data) |
380 | + self.assert_form_displayed(response) |
381 | + |
382 | + def test_post_can_not_reset_password(self): |
383 | + self.mock_captcha_verify.return_value = True |
384 | + exc = api_errors.CanNotResetPassword(Mock()) |
385 | + self.mock_api_request_password_reset.side_effect = exc |
386 | + response = self.post(data=self.data) |
387 | + self.assert_form_displayed(response) |
388 | + |
389 | + def test_post_too_many_tokens(self): |
390 | + self.mock_captcha_verify.return_value = True |
391 | + exc = api_errors.TooManyTokens(Mock()) |
392 | + self.mock_api_request_password_reset.side_effect = exc |
393 | + response = self.post(data=self.data) |
394 | + self.assert_form_displayed(response) |
395 | + |
396 | + def test_post_too_many_requests(self): |
397 | + self.mock_captcha_verify.return_value = True |
398 | + exc = api_errors.TooManyRequests(Mock()) |
399 | + self.mock_api_request_password_reset.side_effect = exc |
400 | + response = self.post(data=self.data) |
401 | + self.assert_form_displayed( |
402 | + response, |
403 | + errors={'__all__': [ERROR_TOO_MANY_REQUESTS]}, |
404 | + ) |
405 | + |
406 | + |
407 | @switches(TWOFACTOR=True) |
408 | class TwoFactorSyncTestCase(SSOBaseTestCase): |
409 | """Tests for 2fa OTP synchronization.""" |
410 | diff --git a/src/webui/tests/test_views_ui.py b/src/webui/tests/test_views_ui.py |
411 | index e40f2d4..8bca417 100644 |
412 | --- a/src/webui/tests/test_views_ui.py |
413 | +++ b/src/webui/tests/test_views_ui.py |
414 | @@ -828,6 +828,32 @@ class ForgotPasswordTestCase(LogHandlerTestCase, UIViewsBaseTestCase): |
415 | |
416 | self.assertEqual(self.mock_send_email.call_count, 1) |
417 | |
418 | + @switches(CAPTCHA=True) |
419 | + @patch.object(CaptchaV2, 'verify', return_value=True) |
420 | + def test_forgot_password_email_with_valid_captcha(self, mock_verify): |
421 | + self.client.post( |
422 | + reverse('forgot_password'), |
423 | + {'email': self.email, 'g-recaptcha-response': 'valid'}, |
424 | + ) |
425 | + self.assertEqual(self.mock_send_email.call_count, 1) |
426 | + |
427 | + @switches(CAPTCHA=True) |
428 | + @patch.object(CaptchaV2, 'verify', return_value=False) |
429 | + def test_forgot_password_email_with_invalid_captcha(self, mock_verify): |
430 | + r = self.client.post( |
431 | + reverse('forgot_password'), |
432 | + {'email': self.email, 'g-recaptcha-response': 'invalid'}, |
433 | + ) |
434 | + self.assertEqual(self.mock_send_email.call_count, 0) |
435 | + self.assertTemplateUsed(r, 'registration/forgot_password.html') |
436 | + |
437 | + @switches(CAPTCHA=True) |
438 | + @patch.object(CaptchaV2, 'verify', return_value=False) |
439 | + def test_forgot_password_email_with_no_captcha(self, mock_verify): |
440 | + r = self.client.post(reverse('forgot_password'), {'email': self.email}) |
441 | + self.assertEqual(self.mock_send_email.call_count, 0) |
442 | + self.assertTemplateUsed(r, 'registration/forgot_password.html') |
443 | + |
444 | def test_forgot_password_email_unknown_email(self): |
445 | self.client.post( |
446 | reverse('forgot_password'), {'email': "spamme@example.com"}) |
447 | diff --git a/src/webui/views/registration.py b/src/webui/views/registration.py |
448 | index 095258a..4e6a08c 100644 |
449 | --- a/src/webui/views/registration.py |
450 | +++ b/src/webui/views/registration.py |
451 | @@ -4,6 +4,8 @@ |
452 | |
453 | from __future__ import unicode_literals |
454 | |
455 | +import logging |
456 | + |
457 | from django.conf import settings |
458 | from django.contrib import auth, messages |
459 | from django.contrib.auth.decorators import login_required |
460 | @@ -30,8 +32,10 @@ from identityprovider.forms import ( |
461 | TwoFactorSyncForm, |
462 | ) |
463 | from identityprovider.login import AuthenticationError |
464 | +from identityprovider.models.captcha import CaptchaV2 |
465 | from identityprovider.models.twofactor import login, sync |
466 | from identityprovider.stats import stats |
467 | +from identityprovider.timeline_helpers import get_request_timing_function |
468 | from identityprovider.utils import ( |
469 | add_non_field_error, |
470 | polite_form_errors, |
471 | @@ -82,6 +86,9 @@ ERROR_TOO_MANY_REQUESTS = _('Too many requests. Please try again later.') |
472 | WEB_CREATION_SOURCE = 'web-flow' |
473 | |
474 | |
475 | +logger = logging.getLogger(__name__) |
476 | + |
477 | + |
478 | @redirect_home_if_logged_in |
479 | @check_readonly |
480 | @requires_cookies |
481 | @@ -182,6 +189,9 @@ def new_account(request, token=None): |
482 | def forgot_password(request, token=None): |
483 | # Need this for displaying metrics per RP |
484 | rpconfig = get_rpconfig_from_request(request, token) |
485 | + captcha_required = gargoyle.is_active('CAPTCHA', request) |
486 | + captcha_verified = False |
487 | + captcha_error_message = '' |
488 | |
489 | def collect_stats(key): |
490 | stats.increment('flows.forgot_password', key=key, rpconfig=rpconfig) |
491 | @@ -193,8 +203,27 @@ def forgot_password(request, token=None): |
492 | form = GenericEmailForm(initial={'email': email}) |
493 | |
494 | elif request.method == 'POST': |
495 | + if captcha_required: |
496 | + captcha_solution = request.POST.get('g-recaptcha-response') |
497 | + if captcha_solution: |
498 | + captcha = CaptchaV2() |
499 | + remote_addr = request.environ['REMOTE_ADDR'] |
500 | + timer = get_request_timing_function(request) |
501 | + try: |
502 | + # ignore arbitrary email from unauthenticated user |
503 | + captcha_verified = captcha.verify( |
504 | + captcha_solution, remote_addr, timer=timer |
505 | + ) |
506 | + except Exception: |
507 | + logger.exception("reCaptcha error") |
508 | + |
509 | + if not captcha_solution or not captcha_verified: |
510 | + captcha_error_message = _( |
511 | + 'A captcha challenge is required to complete the request.' |
512 | + ) |
513 | + |
514 | form = GenericEmailForm(request.POST) |
515 | - if form.is_valid(): |
516 | + if form.is_valid() and (captcha_verified or not captcha_required): |
517 | email = form.cleaned_data['email'] |
518 | |
519 | try: |
520 | @@ -249,13 +278,7 @@ def forgot_password(request, token=None): |
521 | 'click on the link in your email.') % msgs |
522 | |
523 | return display_email_sent( |
524 | - request, |
525 | - email, |
526 | - heading, |
527 | - reason, |
528 | - _("Check that you’ve actually " |
529 | - "entered a subscribed email address."), |
530 | - rpconfig=rpconfig, |
531 | + request, email, heading, reason, rpconfig=rpconfig |
532 | ) |
533 | |
534 | else: |
535 | @@ -266,7 +289,13 @@ def forgot_password(request, token=None): |
536 | else: |
537 | return HttpResponseNotAllowed(['GET', 'POST']) |
538 | |
539 | - context = dict(form=form, rpconfig=rpconfig) |
540 | + context = { |
541 | + 'form': form, |
542 | + 'rpconfig': rpconfig, |
543 | + 'captcha_required': captcha_required, |
544 | + 'captcha_error_message': captcha_error_message, |
545 | + 'CAPTCHA_PUBLIC_KEY': settings.CAPTCHA_PUBLIC_KEY, |
546 | + } |
547 | template = 'registration/forgot_password.html' |
548 | return TemplateResponse(request, template, context) |
549 | |
550 | diff --git a/src/webui/views/ui.py b/src/webui/views/ui.py |
551 | index 373347c..fa9c42e 100644 |
552 | --- a/src/webui/views/ui.py |
553 | +++ b/src/webui/views/ui.py |
554 | @@ -614,23 +614,20 @@ def confirm_email(request, authtoken, email_address, token=None): |
555 | ) |
556 | |
557 | if request.method == 'POST': |
558 | - captcha_solution = request.POST.get('g-recaptcha-response') |
559 | verified = False |
560 | - |
561 | - if not captcha_solution and captcha_required: |
562 | - captcha_error_message = _('A captcha challenge is required' |
563 | - ' to complete the request.') |
564 | - |
565 | - elif captcha_solution: |
566 | - remote_addr = request.environ['REMOTE_ADDR'] |
567 | - try: |
568 | - timer = get_request_timing_function(request) |
569 | - verified = captcha.verify( |
570 | - captcha_solution, remote_addr, email_address, timer=timer) |
571 | - except Exception: |
572 | - logger.exception("reCaptcha error") |
573 | - |
574 | - if not verified: |
575 | + if captcha_required: |
576 | + captcha_solution = request.POST.get('g-recaptcha-response') |
577 | + if captcha_solution: |
578 | + remote_addr = request.environ['REMOTE_ADDR'] |
579 | + try: |
580 | + timer = get_request_timing_function(request) |
581 | + verified = captcha.verify( |
582 | + captcha_solution, remote_addr, email_address, timer |
583 | + ) |
584 | + except Exception: |
585 | + logger.exception("reCaptcha error") |
586 | + |
587 | + if not captcha_solution or not verified: |
588 | captcha_error_message = _('A captcha challenge is required' |
589 | ' to complete the request.') |
590 | |
591 | diff --git a/src/webui/views/utils.py b/src/webui/views/utils.py |
592 | index 63984f7..18f40fe 100644 |
593 | --- a/src/webui/views/utils.py |
594 | +++ b/src/webui/views/utils.py |
595 | @@ -26,13 +26,13 @@ def allow_only(*methods): |
596 | return decorator |
597 | |
598 | |
599 | -def display_email_sent(request, email, heading, reason, extra=None, token=None, |
600 | - rpconfig=None): |
601 | +def display_email_sent( |
602 | + request, email, heading, reason, token=None, rpconfig=None |
603 | +): |
604 | context = { |
605 | 'email_feedback': settings.FEEDBACK_TO_ADDRESS, |
606 | 'email_heading': heading, |
607 | 'email_reason': reason, |
608 | - 'email_notreceived_extra': extra, |
609 | 'email': email, |
610 | 'token': token, |
611 | 'rpconfig': rpconfig, |
Comments for reviewers.