Merge ~maxiberta/canonical-identity-provider:forgot-password-captcha into canonical-identity-provider:master

Proposed by Maximiliano Bertacchini
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)
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.utils.display_email_sent().
- 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://pypi.org/project/django-recaptcha/ :D

To post a comment you must log in.
Revision history for this message
Maximiliano Bertacchini (maxiberta) wrote :

Comments for reviewers.

Revision history for this message
Maximiliano Bertacchini (maxiberta) wrote :

Updated comments for reviewers.

Revision history for this message
Guillermo Gonzalez (verterok) wrote :

LGTM

review: Approve
Revision history for this message
Hasan Ammar (hasanammar) wrote :

Looks good, but see an inline suggestion and a comment. Thanks.

Revision history for this message
Maximiliano Bertacchini (maxiberta) wrote :

Thanks! Will push a improvements shortly.

Revision history for this message
Hasan Ammar (hasanammar) wrote :

Looks great, thanks for the improvements!

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/src/identityprovider/models/captcha.py b/src/identityprovider/models/captcha.py
2index 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:
37diff --git a/src/identityprovider/tests/test_models_captcha.py b/src/identityprovider/tests/test_models_captcha.py
38index 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
58diff --git a/src/webui/templates/account/confirm_new_email.html b/src/webui/templates/account/confirm_new_email.html
59index 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>
78diff --git a/src/webui/templates/registration/forgot_password.html b/src/webui/templates/registration/forgot_password.html
79index 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>
92diff --git a/src/webui/templates/widgets/recaptcha-v2.html b/src/webui/templates/widgets/recaptcha-v2.html
93index 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>
107diff --git a/src/webui/tests/test_loginservice.py b/src/webui/tests/test_loginservice.py
108index 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()
154diff --git a/src/webui/tests/test_views_registration.py b/src/webui/tests/test_views_registration.py
155index 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."""
410diff --git a/src/webui/tests/test_views_ui.py b/src/webui/tests/test_views_ui.py
411index 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"})
447diff --git a/src/webui/views/registration.py b/src/webui/views/registration.py
448index 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&rsquo;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
550diff --git a/src/webui/views/ui.py b/src/webui/views/ui.py
551index 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
591diff --git a/src/webui/views/utils.py b/src/webui/views/utils.py
592index 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,

Subscribers

People subscribed via source and target branches