provide generic input code logic (#69890)
gitea/authentic/pipeline/head This commit looks good Details

Suited to both registration & password-change actions.
This commit is contained in:
Paul Marillonnet 2022-12-21 10:42:16 +01:00
parent af8adcef71
commit ba9550511e
6 changed files with 166 additions and 36 deletions

View File

@ -195,9 +195,9 @@ class RegistrationCompletionForm(RegistrationCompletionFormNoPassword):
return self.cleaned_data
class InputRegistrationCodeForm(Form):
registration_code = CharField(
label=_('Registration code'),
help_text=_('The registration code you received by SMS.'),
class InputSMSCodeForm(Form):
sms_code = CharField(
label=_('SMS code'),
help_text=_('The code you received by SMS.'),
max_length=settings.SMS_CODE_LENGTH,
)

View File

@ -7,7 +7,7 @@
{% block content %}
<form method="post" action=".">
<p>{% blocktrans trimmed %}Input your account activation code.{% endblocktrans %}</p>
<p>{% blocktrans trimmed %}Input the code you received by SMS.{% endblocktrans %}</p>
<p>
{% blocktrans count counter=duration %}
Your code is valid for the next minute.

View File

@ -116,8 +116,8 @@ urlpatterns = [
),
re_path(
'^register/input_code/(?P<token>[A-Za-z0-9_ -]+)/$',
views.input_registration_code,
name='input_registration_code',
views.input_sms_code,
name='input_sms_code',
),
# Password reset
re_path(

View File

@ -854,7 +854,7 @@ class PasswordResetView(FormView):
if not app_settings.A2_ACCEPT_PHONE_AUTHENTICATION or not self.code: # user input is email
return reverse('password_reset_instructions')
else: # user input is phone number
return reverse('input_registration_code', kwargs={'token': self.code.url_token})
return reverse('input_sms_code', kwargs={'token': self.code.url_token})
def get_template_names(self):
return [
@ -1208,7 +1208,7 @@ class BaseRegistrationView(HomeURLMixin, FormView):
messages.warning(
self.request,
_(
'Something went wrong while trying to send the SMS registration code to you.'
'Something went wrong while trying to send the SMS code to you.'
' Please contact your administrator and try again later.'
),
)
@ -1217,7 +1217,7 @@ class BaseRegistrationView(HomeURLMixin, FormView):
self.request.session['registered_phone'] = phone
return utils_misc.redirect(
self.request,
reverse('input_registration_code', kwargs={'token': code.url_token}),
reverse('input_sms_code', kwargs={'token': code.url_token}),
params={REDIRECT_FIELD_NAME: self.next_url, 'token': code.url_token},
)
@ -1296,20 +1296,18 @@ class BaseRegistrationView(HomeURLMixin, FormView):
return context
class InputRegistrationCodeView(cbv.ValidateCSRFMixin, FormView):
template_name = 'registration/sms_input_registration_code.html'
form_class = registration_forms.InputRegistrationCodeForm
class InputSMSCodeView(cbv.ValidateCSRFMixin, FormView):
template_name = 'registration/sms_input_code.html'
form_class = registration_forms.InputSMSCodeForm
success_url = '/accounts/'
title = _('Account activation')
title = _('SMS code validation')
def dispatch(self, request, *args, **kwargs):
token = kwargs.get('token')
try:
self.code = models.SMSCode.objects.get(url_token=token)
except models.SMSCode.DoesNotExist:
return HttpResponseBadRequest(_('Invalid account activation request'))
if not self.code.sent:
return HttpResponseBadRequest(_('Invalid account activation code'))
return HttpResponseBadRequest(_('Invalid request'))
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
@ -1326,35 +1324,43 @@ class InputRegistrationCodeView(cbv.ValidateCSRFMixin, FormView):
@atomic(savepoint=False)
def form_valid(self, form):
super().form_valid(form)
registration_code = form.cleaned_data.pop('registration_code')
if self.code.value != registration_code:
sms_code = form.cleaned_data.pop('sms_code')
if self.code.value != sms_code or self.code.fake:
# TODO ratelimit on erroneous code inputs(?)
# (code expires after 120 seconds)
form.add_error('registration_code', _('Wrong registration code.'))
form.add_error('sms_code', _('Wrong SMS code.'))
return self.form_invalid(form)
if self.code.expires < timezone.now():
form.add_error('registration_code', _('The code has expired.'))
form.add_error('sms_code', _('The code has expired.'))
return self.form_invalid(form)
Lock.lock_identifier(self.code.phone)
content = {
# TODO missing ou registration management
'authentication_method': 'phone',
'phone': self.code.phone,
'user': self.code.user.pk if self.code.user else None,
}
# create token to process final account activation and user-defined attributes
token = models.Token.create(
kind='registration',
kind=self.code.CODE_TO_TOKEN_KINDS[self.code.kind],
content=content,
duration=120,
)
return utils_misc.redirect(
# TODO next_url management throughout account creation process
self.request,
reverse('registration_activate', kwargs={'registration_token': token.uuid}),
)
# TODO next_url management throughout account creation process
if self.code.kind == models.SMSCode.KIND_REGISTRATION:
return utils_misc.redirect(
self.request,
reverse('registration_activate', kwargs={'registration_token': token.uuid}),
)
elif self.code.kind == models.SMSCode.KIND_PASSWORD_LOST:
return utils_misc.redirect(
self.request,
reverse('password_reset_confirm', kwargs={'token': token.uuid}),
)
input_registration_code = InputRegistrationCodeView.as_view()
input_sms_code = InputSMSCodeView.as_view()
class RegistrationView(cbv.ValidateCSRFMixin, BaseRegistrationView):

View File

@ -13,15 +13,33 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import json
import pytest
from django.contrib.auth import authenticate
from django.test.utils import override_settings
from django.urls import reverse
from httmock import HTTMock, remember_called, urlmatch
from authentic2.models import SMSCode, Token
from authentic2.utils.misc import send_password_reset_mail
from . import utils
@urlmatch(netloc='foo.whatever.none')
@remember_called
def sms_service_mock(url, request):
return {
'content': {},
'headers': {
'content-type': 'application/json',
},
'status_code': 200,
}
def test_send_password_reset_email(app, simple_user, mailoutbox):
assert len(mailoutbox) == 0
with utils.run_on_commit_hooks():
@ -41,6 +59,112 @@ def test_send_password_reset_email(app, simple_user, mailoutbox):
utils.assert_event('user.password.reset', user=simple_user, session=app.session)
def test_send_password_reset_by_sms_code_improperly_configured(app, nomail_user, settings):
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
settings.SMS_URL = 'https://foo.whatever.none/'
assert not SMSCode.objects.count()
assert not Token.objects.count()
url = reverse('password_reset')
resp = app.get(url, status=200)
resp.form.set('phone_1', '0123456789')
resp = resp.form.submit().follow().maybe_follow()
assert 'Something went wrong while trying to send' in resp.pyquery('li.error').text()
def test_send_password_reset_by_sms_code(app, nomail_user, settings):
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
settings.SMS_URL = 'https://foo.whatever.none/'
code_length = settings.SMS_CODE_LENGTH
assert not SMSCode.objects.count()
assert not Token.objects.count()
url = reverse('password_reset')
resp = app.get(url, status=200)
resp.form.set('phone_1', '0123456789')
with HTTMock(sms_service_mock):
resp = resp.form.submit().follow().maybe_follow()
body = json.loads(sms_service_mock.call['requests'][0].body)
assert body['message'].startswith('Your code is')
code = SMSCode.objects.get()
assert body['message'][-code_length:] == code.value
assert ("Your code is valid for the next %s minute" % (SMSCode.CODE_DURATION // 60)) in resp.text
assert "The code you received by SMS." in resp.text
resp.form.set('sms_code', code.value)
resp = resp.form.submit().follow()
assert Token.objects.count() == 1
assert authenticate(username='user', password='1234==aA') is None
resp.form.set('new_password1', '1234==aA')
resp.form.set('new_password2', '1234==aA')
resp.form.submit()
# verify user is logged
assert str(app.session['_auth_user_id']) == str(nomail_user.pk)
user = authenticate(username='user', password='1234==aA')
assert user == nomail_user
with override_settings(A2_USER_CAN_RESET_PASSWORD=False):
url = reverse('password_reset')
app.get(url, status=404)
def test_password_reset_empty_form(app, db, settings):
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
settings.SMS_URL = 'https://foo.whatever.none/'
url = reverse('password_reset')
resp = app.get(url, status=200)
resp = resp.form.submit()
assert 'There were errors processing your form.' in resp.pyquery('div.errornotice').text()
assert (
'Please provide an email address or a mobile phone number.' in resp.pyquery('div.errornotice').text()
)
def test_password_reset_both_fields_filled_email_precedence(app, simple_user, settings, mailoutbox):
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
settings.SMS_URL = 'https://foo.whatever.none/'
url = reverse('password_reset')
resp = app.get(url, status=200)
resp.form.set('email', simple_user.email)
resp.form.set('phone_1', '0123456789')
resp = resp.form.submit()
utils.assert_event('user.password.reset.request', user=simple_user, email=simple_user.email)
assert resp['Location'].endswith('/instructions/')
resp = resp.follow()
assert len(mailoutbox) == 1
assert not SMSCode.objects.count()
def test_send_password_reset_by_sms_code_erroneous_phone_number(app, nomail_user, settings):
settings.A2_ACCEPT_PHONE_AUTHENTICATION = True
settings.SMS_URL = 'https://foo.whatever.none/'
assert not SMSCode.objects.count()
assert not Token.objects.count()
url = reverse('password_reset')
resp = app.get(url, status=200)
resp.form.set('phone_1', '0111111111')
resp = resp.form.submit().follow().maybe_follow()
assert 'Something went wrong while trying to send' not in resp.text
assert 'error' not in resp.text
assert resp.pyquery('title').text() == 'Authentic2 - testserver - SMS code validation'
code = SMSCode.objects.get()
assert code.fake
resp.form.set('sms_code', 'whatever')
resp = resp.form.submit()
assert resp.pyquery('ul.errorlist').text() == 'Wrong SMS code.'
# even if the correct value is guessed, the code is still fake & not valid whatsoever
resp.form.set('sms_code', code.value)
resp = resp.form.submit()
assert resp.pyquery('ul.errorlist').text() == 'Wrong SMS code.'
assert not Token.objects.count()
def test_reset_by_email(app, simple_user, mailoutbox, settings):
url = reverse('password_reset')
resp = app.get(url, status=200)

View File

@ -978,10 +978,10 @@ def test_phone_registration_wrong_code(app, db, settings):
resp.form.set('phone_1', '612345678')
with HTTMock(sms_service_mock):
resp = resp.form.submit().follow()
resp.form.set('registration_code', 'abc')
resp.form.set('sms_code', 'abc')
resp = resp.form.submit()
assert not Token.objects.count()
assert resp.pyquery('li')[0].text_content() == 'Wrong registration code.'
assert resp.pyquery('li')[0].text_content() == 'Wrong SMS code.'
def test_phone_registration_expired_code(app, db, settings, freezer):
@ -993,7 +993,7 @@ def test_phone_registration_expired_code(app, db, settings, freezer):
with HTTMock(sms_service_mock):
resp = resp.form.submit().follow()
code = SMSCode.objects.get()
resp.form.set('registration_code', code.value)
resp.form.set('sms_code', code.value)
freezer.move_to(timedelta(hours=1))
resp = resp.form.submit()
assert not Token.objects.count()
@ -1009,7 +1009,7 @@ def test_phone_registration_cancel(app, db, settings, freezer):
with HTTMock(sms_service_mock):
resp = resp.form.submit().follow()
code = SMSCode.objects.get()
resp.form.set('registration_code', code.value)
resp.form.set('sms_code', code.value)
resp.form.submit('cancel').follow()
assert not Token.objects.count()
assert not SMSCode.objects.count()
@ -1026,7 +1026,7 @@ def test_phone_registration_improperly_configured(app, db, settings, freezer, ca
assert not Token.objects.count()
assert not SMSCode.objects.count()
assert (
"Something went wrong while trying to send the SMS registration code to you"
"Something went wrong while trying to send the SMS code to you"
in resp.pyquery('li.warning')[0].text_content()
)
assert caplog.records[0].message == 'settings.SMS_URL is not set'
@ -1047,7 +1047,7 @@ def test_phone_registration_connection_error(app, db, settings, freezer, caplog)
mock_send.return_value = mock_response
resp = resp.form.submit().follow().maybe_follow()
assert (
"Something went wrong while trying to send the SMS registration code to you"
"Something went wrong while trying to send the SMS code to you"
in resp.pyquery('li.warning')[0].text_content()
)
assert (
@ -1072,8 +1072,8 @@ def test_phone_registration(app, db, settings):
code = SMSCode.objects.get()
assert body['message'][-code_length:] == code.value
assert ("Your code is valid for the next %s minute" % (SMSCode.CODE_DURATION // 60)) in resp.text
assert "The registration code you received by SMS." in resp.text
resp.form.set('registration_code', code.value)
assert "The code you received by SMS." in resp.text
resp.form.set('sms_code', code.value)
resp = resp.form.submit().follow()
assert Token.objects.count() == 1