Skip to content

Commit 949e6d3

Browse files
committed
[#2813] Refactor math captcha on contactform
- replace dedicated MathCaptcha field with simple IntegerField and perform actual captcha logic in the form view (the field was not thread-safe, the values - question + answer - had to be stored in the session, so there is no reason to keep the field)
1 parent 5391b5f commit 949e6d3

File tree

8 files changed

+102
-127
lines changed

8 files changed

+102
-127
lines changed

src/open_inwoner/components/templates/components/Contact/ContactForm.html

+1-1
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
</p>
3232
<label class="label captcha__label">
3333
<span class="captcha__check">
34-
{{ form_object.captcha.field.question }}
34+
{{ form_object.request_session.captcha_question }}
3535
<span class="captcha__input">{% field_as_widget form_object.captcha "input" form_id %}</span>
3636
</span>
3737
{% if form_object.captcha.errors %}

src/open_inwoner/mail/service.py

+3
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33

44
def send_contact_confirmation_mail(recipient_email: str, form_subject: str):
5+
import pdbr
6+
7+
pdbr.set_trace()
58
template = find_template("contactform_confirmation")
69
context = {
710
"subject": form_subject,

src/open_inwoner/openklant/forms.py

+17-3
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
from open_inwoner.accounts.models import User
55
from open_inwoner.openklant.models import ContactFormSubject, OpenKlantConfig
6-
from open_inwoner.utils.forms import MathCaptchaField
6+
from open_inwoner.openklant.views.utils import generate_question_answer_pair
77
from open_inwoner.utils.validators import DutchPhoneNumberValidator
88

99

@@ -45,16 +45,17 @@ class ContactForm(forms.Form):
4545
widget=forms.Textarea(attrs={"rows": "5"}),
4646
required=True,
4747
)
48-
captcha = MathCaptchaField(
48+
captcha = forms.IntegerField(
4949
label=_("Beantwoord deze rekensom"),
5050
required=True,
5151
)
5252

5353
user: User
5454

55-
def __init__(self, user, *args, **kwargs):
55+
def __init__(self, user, request_session, *args, **kwargs):
5656
super().__init__(*args, **kwargs)
5757
self.user = user
58+
self.request_session = request_session
5859

5960
config = OpenKlantConfig.get_solo()
6061
self.fields["subject"].queryset = config.contactformsubject_set.all()
@@ -91,5 +92,18 @@ def clean(self, *args, **kwargs):
9192
cleaned_data["first_name"] = self.user.first_name
9293
cleaned_data["infix"] = self.user.infix
9394
cleaned_data["last_name"] = self.user.last_name
95+
else:
96+
# captcha for anon users
97+
captcha_answer = cleaned_data.get("captcha")
98+
if (
99+
captcha_answer
100+
and not captcha_answer == self.request_session["captcha_answer"]
101+
):
102+
self.add_error("captcha", _("Fout antwoord, probeer het opnieuw."))
103+
104+
captcha_question, captcha_answer = generate_question_answer_pair()
105+
106+
self.request_session["captcha_question"] = captcha_question
107+
self.request_session["captcha_answer"] = captcha_answer
94108

95109
return cleaned_data

src/open_inwoner/openklant/tests/test_contactform.py

+41-17
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
from open_inwoner.openklant.tests.factories import ContactFormSubjectFactory
1818
from open_inwoner.openklant.views.contactform import ContactFormView
1919
from open_inwoner.openzaak.tests.factories import ServiceFactory
20-
from open_inwoner.utils.forms import MathCaptchaField
2120
from open_inwoner.utils.test import ClearCachesMixin, DisableRequestLogMixin
2221
from open_inwoner.utils.tests.helpers import AssertFormMixin, AssertTimelineLogMixin
2322

@@ -26,11 +25,15 @@
2625
@modify_settings(
2726
MIDDLEWARE={"remove": ["open_inwoner.kvk.middleware.KvKLoginMiddleware"]}
2827
)
29-
@patch.object(MathCaptchaField, "clean", autospec=True)
3028
@patch(
3129
"open_inwoner.openklant.views.contactform.send_contact_confirmation_mail",
3230
autospec=True,
3331
)
32+
@patch(
33+
"open_inwoner.openklant.views.contactform.generate_question_answer_pair",
34+
autospec=True,
35+
return_value=("", 42),
36+
)
3437
class ContactFormIntegrationTest(
3538
ClearCachesMixin,
3639
AssertTimelineLogMixin,
@@ -64,7 +67,7 @@ def setUp(self):
6467
ContactFormView.template_name = "pages/contactform/form.html"
6568

6669
def test_singleton_has_configuration_method(
67-
self, m, mock_send_confirm, mock_captcha
70+
self, m, mock_captcha, mock_send_confirm
6871
):
6972
# use cleared (from setUp()
7073
config = OpenKlantConfig.get_solo()
@@ -92,7 +95,7 @@ def test_singleton_has_configuration_method(
9295
mock_send_confirm.assert_not_called()
9396

9497
def test_no_form_shown_if_not_has_configuration(
95-
self, m, mock_send_confirm, mock_captcha
98+
self, m, mock_captcha, mock_send_confirm
9699
):
97100
# set nothing
98101
config = OpenKlantConfig.get_solo()
@@ -103,7 +106,7 @@ def test_no_form_shown_if_not_has_configuration(
103106
self.assertEqual(0, len(response.pyquery("#contactmoment-form")))
104107

105108
def test_anon_form_requires_either_email_or_phonenumber(
106-
self, m, mock_send_confirm, mock_captcha
109+
self, m, mock_captcha, mock_send_confirm
107110
):
108111
config = OpenKlantConfig.get_solo()
109112
config.register_email = "[email protected]"
@@ -132,6 +135,7 @@ def test_anon_form_requires_either_email_or_phonenumber(
132135
form["email"] = ""
133136
form["phonenumber"] = ""
134137
form["question"] = "hey!\n\nwaddup?"
138+
form["captcha"] = 42
135139

136140
response = form.submit(status=200)
137141
self.assertEqual(
@@ -140,7 +144,7 @@ def test_anon_form_requires_either_email_or_phonenumber(
140144
mock_send_confirm.assert_not_called()
141145

142146
def test_regular_auth_form_fills_email_and_phonenumber(
143-
self, m, mock_send_confirm, mock_captcha
147+
self, m, mock_captcha, mock_send_confirm
144148
):
145149
config = OpenKlantConfig.get_solo()
146150
config.register_email = "[email protected]"
@@ -165,7 +169,7 @@ def test_regular_auth_form_fills_email_and_phonenumber(
165169
mock_send_confirm.assert_called_once_with(user.email, subject.subject)
166170

167171
def test_expected_ordered_subjects_are_shown(
168-
self, m, mock_send_confirm, mock_captcha
172+
self, m, mock_captcha, mock_send_confirm
169173
):
170174
config = OpenKlantConfig.get_solo()
171175
config.register_email = "[email protected]"
@@ -203,7 +207,7 @@ def test_expected_ordered_subjects_are_shown(
203207
)
204208
mock_send_confirm.assert_not_called()
205209

206-
def test_submit_and_register_via_email(self, m, mock_send_confirm, mock_captcha):
210+
def test_submit_and_register_via_email(self, m, mock_captcha, mock_send_confirm):
207211
config = OpenKlantConfig.get_solo()
208212
config.register_email = "[email protected]"
209213
config.has_form_configuration = True
@@ -219,6 +223,7 @@ def test_submit_and_register_via_email(self, m, mock_send_confirm, mock_captcha)
219223
form["email"] = "[email protected]"
220224
form["phonenumber"] = "+31612345678"
221225
form["question"] = "hey!\n\nwaddup?"
226+
form["captcha"] = 42
222227

223228
response = form.submit().follow()
224229

@@ -244,7 +249,7 @@ def test_submit_and_register_via_email(self, m, mock_send_confirm, mock_captcha)
244249
mock_send_confirm.assert_called_once_with("[email protected]", subject.subject)
245250

246251
def test_submit_and_register_anon_via_api_with_klant(
247-
self, m, mock_send_confirm, mock_captcha
252+
self, m, mock_captcha, mock_send_confirm
248253
):
249254
MockAPICreateData.setUpServices()
250255

@@ -273,6 +278,7 @@ def test_submit_and_register_anon_via_api_with_klant(
273278
form["email"] = "[email protected]"
274279
form["phonenumber"] = "+31612345678"
275280
form["question"] = "hey!\n\nwaddup?"
281+
form["captcha"] = 42
276282

277283
response = form.submit().follow()
278284

@@ -329,7 +335,7 @@ def test_submit_and_register_anon_via_api_with_klant(
329335
mock_send_confirm.assert_called_once_with("[email protected]", subject.subject)
330336

331337
def test_submit_and_register_anon_via_api_without_klant(
332-
self, m, mock_send_confirm, mock_captcha
338+
self, m, mock_captcha, mock_send_confirm
333339
):
334340
MockAPICreateData.setUpServices()
335341

@@ -359,6 +365,7 @@ def test_submit_and_register_anon_via_api_without_klant(
359365
form["email"] = "[email protected]"
360366
form["phonenumber"] = "+31612345678"
361367
form["question"] = "hey!\n\nwaddup?"
368+
form["captcha"] = 42
362369

363370
response = form.submit().follow()
364371

@@ -405,9 +412,13 @@ def test_submit_and_register_anon_via_api_without_klant(
405412
self.assertTimelineLog("registered contactmoment by API")
406413
mock_send_confirm.assert_called_once_with("[email protected]", subject.subject)
407414

415+
@patch("open_inwoner.openklant.forms.generate_question_answer_pair")
408416
def test_submit_and_register_anon_via_api_without_klant_does_not_send_empty_email_or_telephone(
409-
self, m, mock_send_confirm, mock_captcha
417+
self, m, mock_captcha2, mock_captcha, mock_send_confirm
410418
):
419+
# we need to patch the captcha Q&A twice because they are re-generated by the form
420+
mock_captcha2.return_value = ("", 42)
421+
411422
config = OpenKlantConfig.get_solo()
412423
config.register_contact_moment = True
413424
config.register_bronorganisatie_rsin = "123456789"
@@ -441,6 +452,7 @@ def test_submit_and_register_anon_via_api_without_klant_does_not_send_empty_emai
441452
form["question"] = "foobar"
442453
form["phonenumber"] = contact_details["phonenumber"]
443454
form["email"] = contact_details["email"]
455+
form["captcha"] = 42
444456

445457
response = form.submit().follow()
446458

@@ -463,7 +475,7 @@ def test_submit_and_register_anon_via_api_without_klant_does_not_send_empty_emai
463475
self.assertNotIn("telefoonnummer", contactgegevens.keys())
464476

465477
def test_register_bsn_user_via_api_without_id(
466-
self, m, mock_send_confirm, mock_captcha
478+
self, m, mock_captcha, mock_send_confirm
467479
):
468480
MockAPICreateData.setUpServices()
469481

@@ -516,7 +528,7 @@ def test_register_bsn_user_via_api_without_id(
516528
)
517529

518530
def test_submit_and_register_bsn_user_via_api(
519-
self, m, mock_send_confirm, mock_captcha
531+
self, m, mock_captcha, mock_send_confirm
520532
):
521533
MockAPICreateData.setUpServices()
522534

@@ -592,7 +604,7 @@ def test_submit_and_register_bsn_user_via_api(
592604
mock_send_confirm.assert_called_once_with("[email protected]", subject.subject)
593605

594606
def test_submit_and_register_kvk_or_rsin_user_via_api(
595-
self, _m, mock_send_confirm, mock_captcha
607+
self, _m, mock_captcha, mock_send_confirm
596608
):
597609
MockAPICreateData.setUpServices()
598610

@@ -697,7 +709,7 @@ def test_submit_and_register_kvk_or_rsin_user_via_api(
697709
mock_send_confirm.reset_mock()
698710

699711
def test_submit_and_register_bsn_user_via_api_and_update_klant(
700-
self, m, mock_send_confirm, mock_captcha
712+
self, m, mock_captcha, mock_send_confirm
701713
):
702714
MockAPICreateData.setUpServices()
703715

@@ -780,12 +792,16 @@ def test_submit_and_register_bsn_user_via_api_and_update_klant(
780792
mock_send_confirm.assert_called_once_with(data.user.email, subject.subject)
781793
mock_send_confirm.reset_mock()
782794

795+
@patch("open_inwoner.openklant.forms.generate_question_answer_pair")
783796
def test_submit_and_register_kvk_or_rsin_user_via_api_and_update_klant(
784-
self, m, mock_send_confirm, mock_captcha
797+
self, m, mock_captcha2, mock_captcha, mock_send_confirm
785798
):
786799
self.maxDiff = None
787800
MockAPICreateData.setUpServices()
788801

802+
# we need to patch the captcha Q&A twice because they are re-generated by the form
803+
mock_captcha2.return_value = ("", 42)
804+
789805
config = OpenKlantConfig.get_solo()
790806
config.register_contact_moment = True
791807
config.register_bronorganisatie_rsin = "123456789"
@@ -884,11 +900,15 @@ def test_submit_and_register_kvk_or_rsin_user_via_api_and_update_klant(
884900
)
885901
mock_send_confirm.reset_mock()
886902

903+
@patch("open_inwoner.openklant.forms.generate_question_answer_pair")
887904
def test_send_email_confirmation_is_configurable(
888-
self, m, mock_send_confirm, mock_captcha
905+
self, m, mock_captcha2, mock_captcha, mock_send_confirm
889906
):
890907
MockAPICreateData.setUpServices()
891908

909+
# we need to patch the captcha Q&A twice because they are re-generated by the form
910+
mock_captcha2.return_value = ("", 42)
911+
892912
config = OpenKlantConfig.get_solo()
893913
config.register_contact_moment = True
894914
config.register_bronorganisatie_rsin = "123456789"
@@ -918,6 +938,10 @@ def test_send_email_confirmation_is_configurable(
918938
form["email"] = "[email protected]"
919939
form["phonenumber"] = "+31612345678"
920940
form["question"] = "hey!\n\nwaddup?"
941+
form["captcha"] = 42
942+
943+
# res = form.submit()
944+
# import pdbr;pdbr.set_trace()
921945

922946
response = form.submit().follow()
923947

src/open_inwoner/openklant/views/contactform.py

+12
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
)
1818
from open_inwoner.openklant.forms import ContactForm
1919
from open_inwoner.openklant.models import OpenKlantConfig
20+
from open_inwoner.openklant.views.utils import generate_question_answer_pair
2021
from open_inwoner.openklant.wrap import get_fetch_parameters
2122
from open_inwoner.utils.views import CommonPageMixin, LogMixin
2223

@@ -44,7 +45,18 @@ def get_success_url(self):
4445

4546
def get_form_kwargs(self):
4647
kwargs = super().get_form_kwargs()
48+
49+
captcha_question = self.request.session.get("captcha_question")
50+
captcha_answer = self.request.session.get("captcha_answer")
51+
52+
if not (captcha_question and captcha_answer):
53+
captcha_question, captcha_answer = generate_question_answer_pair()
54+
55+
self.request.session["captcha_question"] = captcha_question
56+
self.request.session["captcha_answer"] = captcha_answer
57+
4758
kwargs["user"] = self.request.user
59+
kwargs["request_session"] = self.request.session
4860
return kwargs
4961

5062
def get_initial(self):
+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import secrets
2+
3+
from django.utils.translation import gettext as _
4+
5+
6+
def generate_question_answer_pair(
7+
range_: tuple[int, int] = (1, 10),
8+
operators: list[str] = ["+", "-"],
9+
) -> tuple[str, int]:
10+
lower, upper = range_
11+
num1 = secrets.choice(range(lower, upper))
12+
num2 = secrets.choice(range(lower, upper))
13+
operator = secrets.choice(operators)
14+
15+
# exclude negative results
16+
num1, num2 = max(num1, num2), min(num1, num2)
17+
18+
question = _("What is {num1} {operator} {num2}?").format(
19+
num1=num1, operator=operator, num2=num2
20+
)
21+
22+
match operator:
23+
case "+":
24+
answer = num1 + num2
25+
case "-":
26+
answer = num1 - num2
27+
28+
return question, answer

0 commit comments

Comments
 (0)