Projet

Général

Profil

0005-provide-generic-input-code-logic-69890.patch

Paul Marillonnet, 17 janvier 2023 15:49

Télécharger (16,6 ko)

Voir les différences:

Subject: [PATCH 5/5] provide generic input code logic (#69890)

Suited to both registration & password-change actions.
 src/authentic2/forms/registration.py          |   8 +-
 ...stration_code.html => sms_input_code.html} |   2 +-
 src/authentic2/urls.py                        |   4 +-
 src/authentic2/views.py                       |  50 ++++---
 tests/test_password_reset.py                  | 124 ++++++++++++++++++
 tests/test_registration.py                    |  16 +--
 6 files changed, 168 insertions(+), 36 deletions(-)
 rename src/authentic2/templates/registration/{sms_input_registration_code.html => sms_input_code.html} (87%)
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
-