0005-provide-generic-input-code-logic-69890.patch
src/authentic2/forms/registration.py | ||
---|---|---|
195 | 195 |
return self.cleaned_data |
196 | 196 | |
197 | 197 | |
198 |
class InputRegistrationCodeForm(Form):
|
|
199 |
registration_code = CharField(
|
|
200 |
label=_('Registration code'),
|
|
201 |
help_text=_('The registration code you received by SMS.'),
|
|
198 |
class InputSMSCodeForm(Form):
|
|
199 |
sms_code = CharField(
|
|
200 |
label=_('SMS code'),
|
|
201 |
help_text=_('The code you received by SMS.'), |
|
202 | 202 |
max_length=settings.SMS_CODE_LENGTH, |
203 | 203 |
) |
src/authentic2/templates/registration/sms_input_registration_code.html → src/authentic2/templates/registration/sms_input_code.html | ||
---|---|---|
7 | 7 | |
8 | 8 |
{% block content %} |
9 | 9 |
<form method="post" action="."> |
10 |
<p>{% blocktrans trimmed %}Input your account activation code.{% endblocktrans %}</p>
|
|
10 |
<p>{% blocktrans trimmed %}Input the code you received by SMS.{% endblocktrans %}</p>
|
|
11 | 11 |
<p> |
12 | 12 |
{% blocktrans count counter=duration %} |
13 | 13 |
Your code is valid for the next minute. |
src/authentic2/urls.py | ||
---|---|---|
116 | 116 |
), |
117 | 117 |
re_path( |
118 | 118 |
'^register/input_code/(?P<token>[A-Za-z0-9_ -]+)/$', |
119 |
views.input_registration_code,
|
|
120 |
name='input_registration_code',
|
|
119 |
views.input_sms_code,
|
|
120 |
name='input_sms_code',
|
|
121 | 121 |
), |
122 | 122 |
# Password reset |
123 | 123 |
re_path( |
src/authentic2/views.py | ||
---|---|---|
849 | 849 |
if not app_settings.A2_ACCEPT_PHONE_AUTHENTICATION or not self.code: # user input is email |
850 | 850 |
return reverse('password_reset_instructions') |
851 | 851 |
else: # user input is phone number |
852 |
return reverse('input_registration_code', kwargs={'token': self.code.url_token})
|
|
852 |
return reverse('input_sms_code', kwargs={'token': self.code.url_token})
|
|
853 | 853 | |
854 | 854 |
def get_template_names(self): |
855 | 855 |
return [ |
... | ... | |
1206 | 1206 |
messages.warning( |
1207 | 1207 |
self.request, |
1208 | 1208 |
_( |
1209 |
'Something went wrong while trying to send the SMS registration code to you.'
|
|
1209 |
'Something went wrong while trying to send the SMS code to you.' |
|
1210 | 1210 |
' Please contact your administrator and try again later.' |
1211 | 1211 |
), |
1212 | 1212 |
) |
... | ... | |
1215 | 1215 |
self.request.session['registered_phone'] = phone |
1216 | 1216 |
return utils_misc.redirect( |
1217 | 1217 |
self.request, |
1218 |
reverse('input_registration_code', kwargs={'token': code.url_token}),
|
|
1218 |
reverse('input_sms_code', kwargs={'token': code.url_token}),
|
|
1219 | 1219 |
params={REDIRECT_FIELD_NAME: self.next_url, 'token': code.url_token}, |
1220 | 1220 |
) |
1221 | 1221 | |
... | ... | |
1294 | 1294 |
return context |
1295 | 1295 | |
1296 | 1296 | |
1297 |
class InputRegistrationCodeView(cbv.ValidateCSRFMixin, FormView):
|
|
1298 |
template_name = 'registration/sms_input_registration_code.html'
|
|
1299 |
form_class = registration_forms.InputRegistrationCodeForm
|
|
1297 |
class InputSMSCodeView(cbv.ValidateCSRFMixin, FormView):
|
|
1298 |
template_name = 'registration/sms_input_code.html' |
|
1299 |
form_class = registration_forms.InputSMSCodeForm
|
|
1300 | 1300 |
success_url = '/accounts/' |
1301 |
title = _('Account activation')
|
|
1301 |
title = _('SMS code validation')
|
|
1302 | 1302 | |
1303 | 1303 |
def dispatch(self, request, *args, **kwargs): |
1304 | 1304 |
token = kwargs.get('token') |
1305 | 1305 |
try: |
1306 | 1306 |
self.code = models.SMSCode.objects.get(url_token=token) |
1307 | 1307 |
except models.SMSCode.DoesNotExist: |
1308 |
return HttpResponseBadRequest(_('Invalid account activation request'))
|
|
1309 |
if not self.code.sent: |
|
1310 |
return HttpResponseBadRequest(_('Invalid account activation code'))
|
|
1308 |
return HttpResponseBadRequest(_('Invalid request')) |
|
1309 |
if not self.code.sent and not self.code.fake:
|
|
1310 |
return HttpResponseBadRequest(_('Invalid code')) |
|
1311 | 1311 |
return super().dispatch(request, *args, **kwargs) |
1312 | 1312 | |
1313 | 1313 |
def get_context_data(self, **kwargs): |
... | ... | |
1324 | 1324 |
@atomic(savepoint=False) |
1325 | 1325 |
def form_valid(self, form): |
1326 | 1326 |
super().form_valid(form) |
1327 |
registration_code = form.cleaned_data.pop('registration_code')
|
|
1328 |
if self.code.value != registration_code:
|
|
1327 |
sms_code = form.cleaned_data.pop('sms_code')
|
|
1328 |
if self.code.value != sms_code or self.code.fake:
|
|
1329 | 1329 |
# TODO ratelimit on erroneous code inputs(?) |
1330 | 1330 |
# (code expires after 120 seconds) |
1331 |
form.add_error('registration_code', _('Wrong registration code.'))
|
|
1331 |
form.add_error('sms_code', _('Wrong SMS code.'))
|
|
1332 | 1332 |
return self.form_invalid(form) |
1333 | 1333 |
if self.code.expires < timezone.now(): |
1334 |
form.add_error('registration_code', _('The code has expired.'))
|
|
1334 |
form.add_error('sms_code', _('The code has expired.'))
|
|
1335 | 1335 |
return self.form_invalid(form) |
1336 | 1336 |
Lock.lock_identifier(self.code.phone) |
1337 | 1337 |
content = { |
1338 | 1338 |
# TODO missing ou registration management |
1339 | 1339 |
'authentication_method': 'phone', |
1340 | 1340 |
'phone': self.code.phone, |
1341 |
'user': self.code.user.pk if self.code.user else None, |
|
1341 | 1342 |
} |
1342 | 1343 |
# create token to process final account activation and user-defined attributes |
1343 | 1344 |
token = models.Token.create( |
1344 |
kind='registration',
|
|
1345 |
kind=self.code.kind,
|
|
1345 | 1346 |
content=content, |
1346 | 1347 |
duration=120, |
1347 | 1348 |
) |
1348 |
return utils_misc.redirect( |
|
1349 |
# TODO next_url management throughout account creation process |
|
1350 |
self.request, |
|
1351 |
reverse('registration_activate', kwargs={'registration_token': token.uuid}), |
|
1352 |
) |
|
1349 | ||
1350 |
# TODO next_url management throughout account creation process |
|
1351 |
if self.code.kind == models.SMSCode.KIND_REGISTRATION: |
|
1352 |
return utils_misc.redirect( |
|
1353 |
self.request, |
|
1354 |
reverse('registration_activate', kwargs={'registration_token': token.uuid}), |
|
1355 |
) |
|
1356 |
elif self.code.kind == models.SMSCode.KIND_PASSWORD_LOST: |
|
1357 |
return utils_misc.redirect( |
|
1358 |
self.request, |
|
1359 |
reverse('password_reset_confirm', kwargs={'token': token.uuid}), |
|
1360 |
) |
|
1353 | 1361 | |
1354 | 1362 | |
1355 |
input_registration_code = InputRegistrationCodeView.as_view()
|
|
1363 |
input_sms_code = InputSMSCodeView.as_view()
|
|
1356 | 1364 | |
1357 | 1365 | |
1358 | 1366 |
class RegistrationView(cbv.ValidateCSRFMixin, BaseRegistrationView): |
tests/test_password_reset.py | ||
---|---|---|
13 | 13 |
# |
14 | 14 |
# You should have received a copy of the GNU Affero General Public License |
15 | 15 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
16 | ||
17 |
import json |
|
18 | ||
16 | 19 |
import pytest |
20 |
from django.contrib.auth import authenticate |
|
17 | 21 |
from django.test.utils import override_settings |
18 | 22 |
from django.urls import reverse |
23 |
from httmock import HTTMock, remember_called, urlmatch |
|
19 | 24 | |
25 |
from authentic2.models import SMSCode, Token |
|
20 | 26 |
from authentic2.utils.misc import send_password_reset_mail |
21 | 27 | |
22 | 28 |
from . import utils |
23 | 29 | |
24 | 30 | |
31 |
@urlmatch(netloc='foo.whatever.none') |
|
32 |
@remember_called |
|
33 |
def sms_service_mock(url, request): |
|
34 |
return { |
|
35 |
'content': {}, |
|
36 |
'headers': { |
|
37 |
'content-type': 'application/json', |
|
38 |
}, |
|
39 |
'status_code': 200, |
|
40 |
} |
|
41 | ||
42 | ||
25 | 43 |
def test_send_password_reset_email(app, simple_user, mailoutbox): |
26 | 44 |
assert len(mailoutbox) == 0 |
27 | 45 |
with utils.run_on_commit_hooks(): |
... | ... | |
41 | 59 |
utils.assert_event('user.password.reset', user=simple_user, session=app.session) |
42 | 60 | |
43 | 61 | |
62 |
def test_send_password_reset_by_sms_code_improperly_configured(app, nomail_user, settings): |
|
63 |
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True |
|
64 |
settings.SMS_URL = 'https://foo.whatever.none/' |
|
65 | ||
66 |
assert not SMSCode.objects.count() |
|
67 |
assert not Token.objects.count() |
|
68 | ||
69 |
url = reverse('password_reset') |
|
70 |
resp = app.get(url, status=200) |
|
71 |
resp.form.set('phone_1', '0123456789') |
|
72 |
resp = resp.form.submit().follow().maybe_follow() |
|
73 |
assert 'Something went wrong while trying to send' in resp.pyquery('li.error').text() |
|
74 | ||
75 | ||
76 |
def test_send_password_reset_by_sms_code(app, nomail_user, settings): |
|
77 |
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True |
|
78 |
settings.SMS_URL = 'https://foo.whatever.none/' |
|
79 | ||
80 |
code_length = settings.SMS_CODE_LENGTH |
|
81 |
assert not SMSCode.objects.count() |
|
82 |
assert not Token.objects.count() |
|
83 | ||
84 |
url = reverse('password_reset') |
|
85 |
resp = app.get(url, status=200) |
|
86 |
resp.form.set('phone_1', '0123456789') |
|
87 |
with HTTMock(sms_service_mock): |
|
88 |
resp = resp.form.submit().follow().maybe_follow() |
|
89 |
body = json.loads(sms_service_mock.call['requests'][0].body) |
|
90 |
assert body['message'].startswith('Your code is') |
|
91 |
code = SMSCode.objects.get() |
|
92 |
assert body['message'][-code_length:] == code.value |
|
93 |
assert ("Your code is valid for the next %s minute" % (SMSCode.CODE_DURATION // 60)) in resp.text |
|
94 |
assert "The code you received by SMS." in resp.text |
|
95 |
resp.form.set('sms_code', code.value) |
|
96 |
resp = resp.form.submit().follow() |
|
97 |
assert Token.objects.count() == 1 |
|
98 | ||
99 |
assert authenticate(username='user', password='1234==aA') is None |
|
100 |
resp.form.set('new_password1', '1234==aA') |
|
101 |
resp.form.set('new_password2', '1234==aA') |
|
102 |
resp.form.submit() |
|
103 |
# verify user is logged |
|
104 |
assert str(app.session['_auth_user_id']) == str(nomail_user.pk) |
|
105 |
user = authenticate(username='user', password='1234==aA') |
|
106 |
assert user == nomail_user |
|
107 | ||
108 |
with override_settings(A2_USER_CAN_RESET_PASSWORD=False): |
|
109 |
url = reverse('password_reset') |
|
110 |
app.get(url, status=404) |
|
111 | ||
112 | ||
113 |
def test_password_reset_empty_form(app, settings): |
|
114 |
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True |
|
115 |
settings.SMS_URL = 'https://foo.whatever.none/' |
|
116 | ||
117 |
url = reverse('password_reset') |
|
118 |
resp = app.get(url, status=200) |
|
119 |
resp = resp.form.submit() |
|
120 |
assert 'There were errors processing your form.' in resp.pyquery('div.errornotice').text() |
|
121 |
assert ( |
|
122 |
'Please provide an email address or a mobile phone number.' in resp.pyquery('div.errornotice').text() |
|
123 |
) |
|
124 | ||
125 | ||
126 |
def test_password_reset_both_fields_filled_email_precedence(app, simple_user, settings, mailoutbox): |
|
127 |
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True |
|
128 |
settings.SMS_URL = 'https://foo.whatever.none/' |
|
129 | ||
130 |
url = reverse('password_reset') |
|
131 |
resp = app.get(url, status=200) |
|
132 |
resp.form.set('email', simple_user.email) |
|
133 |
resp.form.set('phone_1', '0123456789') |
|
134 |
resp = resp.form.submit() |
|
135 |
utils.assert_event('user.password.reset.request', user=simple_user, email=simple_user.email) |
|
136 |
assert resp['Location'].endswith('/instructions/') |
|
137 |
resp = resp.follow() |
|
138 |
assert len(mailoutbox) == 1 |
|
139 |
assert not SMSCode.objects.count() |
|
140 | ||
141 | ||
142 |
def test_send_password_reset_by_sms_code_erroneous_phone_number(app, nomail_user, settings): |
|
143 |
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True |
|
144 |
settings.SMS_URL = 'https://foo.whatever.none/' |
|
145 | ||
146 |
assert not SMSCode.objects.count() |
|
147 |
assert not Token.objects.count() |
|
148 | ||
149 |
url = reverse('password_reset') |
|
150 |
resp = app.get(url, status=200) |
|
151 |
resp.form.set('phone_1', '0111111111') |
|
152 |
resp = resp.form.submit().follow().maybe_follow() |
|
153 |
assert 'Something went wrong while trying to send' not in resp.text |
|
154 |
assert 'error' not in resp.text |
|
155 |
assert resp.pyquery('title').text() == 'SMS code validation' |
|
156 |
code = SMSCode.objects.get() |
|
157 |
assert code.fake |
|
158 |
resp.form.set('sms_code', 'whatever') |
|
159 |
resp = resp.form.submit() |
|
160 |
assert resp.pyquery('ul.errorlist').text() == 'Wrong SMS code.' |
|
161 |
# even if the correct value is guessed, the code is still fake & not valid whatsoever |
|
162 |
resp.form.set('sms_code', code.value) |
|
163 |
resp = resp.form.submit() |
|
164 |
assert resp.pyquery('ul.errorlist').text() == 'Wrong SMS code.' |
|
165 |
assert not Token.objects.count() |
|
166 | ||
167 | ||
44 | 168 |
def test_reset_by_email(app, simple_user, mailoutbox, settings): |
45 | 169 |
url = reverse('password_reset') |
46 | 170 |
resp = app.get(url, status=200) |
tests/test_registration.py | ||
---|---|---|
979 | 979 |
resp.form.set('phone_1', '612345678') |
980 | 980 |
with HTTMock(sms_service_mock): |
981 | 981 |
resp = resp.form.submit().follow() |
982 |
resp.form.set('registration_code', 'abc')
|
|
982 |
resp.form.set('sms_code', 'abc')
|
|
983 | 983 |
resp = resp.form.submit() |
984 | 984 |
assert not Token.objects.count() |
985 |
assert resp.pyquery('li')[0].text_content() == 'Wrong registration code.'
|
|
985 |
assert resp.pyquery('li')[0].text_content() == 'Wrong SMS code.'
|
|
986 | 986 | |
987 | 987 | |
988 | 988 |
def test_phone_registration_expired_code(app, db, settings, freezer): |
... | ... | |
994 | 994 |
with HTTMock(sms_service_mock): |
995 | 995 |
resp = resp.form.submit().follow() |
996 | 996 |
code = SMSCode.objects.get() |
997 |
resp.form.set('registration_code', code.value)
|
|
997 |
resp.form.set('sms_code', code.value)
|
|
998 | 998 |
freezer.move_to(timedelta(hours=1)) |
999 | 999 |
resp = resp.form.submit() |
1000 | 1000 |
assert not Token.objects.count() |
... | ... | |
1010 | 1010 |
with HTTMock(sms_service_mock): |
1011 | 1011 |
resp = resp.form.submit().follow() |
1012 | 1012 |
code = SMSCode.objects.get() |
1013 |
resp.form.set('registration_code', code.value)
|
|
1013 |
resp.form.set('sms_code', code.value)
|
|
1014 | 1014 |
resp.form.submit('cancel').follow() |
1015 | 1015 |
assert not Token.objects.count() |
1016 | 1016 |
assert not SMSCode.objects.count() |
... | ... | |
1027 | 1027 |
assert not Token.objects.count() |
1028 | 1028 |
assert not SMSCode.objects.count() |
1029 | 1029 |
assert ( |
1030 |
"Something went wrong while trying to send the SMS registration code to you"
|
|
1030 |
"Something went wrong while trying to send the SMS code to you" |
|
1031 | 1031 |
in resp.pyquery('li.warning')[0].text_content() |
1032 | 1032 |
) |
1033 | 1033 |
assert caplog.records[0].message == 'settings.SMS_URL is not set' |
... | ... | |
1048 | 1048 |
mock_send.return_value = mock_response |
1049 | 1049 |
resp = resp.form.submit().follow().maybe_follow() |
1050 | 1050 |
assert ( |
1051 |
"Something went wrong while trying to send the SMS registration code to you"
|
|
1051 |
"Something went wrong while trying to send the SMS code to you" |
|
1052 | 1052 |
in resp.pyquery('li.warning')[0].text_content() |
1053 | 1053 |
) |
1054 | 1054 |
assert ( |
... | ... | |
1073 | 1073 |
code = SMSCode.objects.get() |
1074 | 1074 |
assert body['message'][-code_length:] == code.value |
1075 | 1075 |
assert ("Your code is valid for the next %s minute" % (SMSCode.CODE_DURATION // 60)) in resp.text |
1076 |
assert "The registration code you received by SMS." in resp.text
|
|
1077 |
resp.form.set('registration_code', code.value)
|
|
1076 |
assert "The code you received by SMS." in resp.text |
|
1077 |
resp.form.set('sms_code', code.value)
|
|
1078 | 1078 |
resp = resp.form.submit().follow() |
1079 | 1079 |
assert Token.objects.count() == 1 |
1080 | 1080 | |
1081 |
- |