page “Mon compte” : action de validation d’un numéro de téléphone existant (#82388) #152

Open
pmarillonnet wants to merge 1 commits from wip/82388-phone-authn-accounts-verification-label into main
10 changed files with 773 additions and 51 deletions

View File

@ -86,6 +86,25 @@ class PhoneChangeForm(PhoneChangeFormNoPassword):
return password
class PhoneValidateForm(NextUrlFormMixin, forms.Form):
phone = forms.CharField(widget=forms.HiddenInput)
def __init__(self, user, *args, **kwargs):
super().__init__(*args, **kwargs)
self.user = user
def clean(self):
if not self.user.phone_identifier:
raise forms.ValidationError(
_("You don't have a phone number yet.")
) # should not happen as user is redirected to phone-change view
if self.user.phone_verified_on is not None:
raise forms.ValidationError(
_('Your phone number has already been validated.')
) # should not happen either
return super().clean()
class BaseUserForm(LockedFieldFormMixin, forms.ModelForm):
error_messages = {
'duplicate_username': _('A user with that username already exists.'),

View File

@ -823,11 +823,13 @@ class SMSCode(models.Model):
KIND_PASSWORD_LOST = 'password-reset'
KIND_PHONE_CHANGE = 'phone-change'
KIND_ACCOUNT_DELETION = 'account-deletion'
KIND_PHONE_VALIDATE = 'phone-validate'
Review

Est-ce qu'on pourrait réutiliser phone-change plutôt ? La situation me parait de loin identique.

Est-ce qu'on pourrait réutiliser phone-change plutôt ? La situation me parait de loin identique.
CODE_TO_TOKEN_KINDS = {
KIND_REGISTRATION: 'registration',
KIND_PASSWORD_LOST: 'pw-reset',
KIND_PHONE_CHANGE: 'phone-change',
KIND_ACCOUNT_DELETION: 'account-deletion',
KIND_PHONE_VALIDATE: 'phone-validate',
}
value = models.CharField(
verbose_name=_('Identifier'), default=create_sms_code, editable=False, max_length=32

View File

@ -71,6 +71,9 @@
{% if allow_phone_change %}
<p><a href="{% url 'phone-change' %}">{% if phone %}{% trans "Change phone" %}{% else %}{% trans "Declare your phone number" %}{% endif %}</a></p>
{% endif %}
{% if allow_phone_validate %}
<p><a href="{% url 'phone-validate' %}">{% trans "Validate your existing phone number" %}</a></p>
{% endif %}
{% if allow_profile_edit %}
<p><a href="{% url 'profile_edit' %}">{% trans "Edit account data" %}</a></p>
{% endif %}

View File

@ -0,0 +1,25 @@
{% extends "authentic2/base-page.html" %}
{% load i18n gadjo %}
{% block title %}
{{ view.title }}
{% endblock %}
{% block breadcrumb %}
{{ block.super }}
<a href="..">{% trans "Your account" %}</a>
<a href="">{{ view.title }}</a>
{% endblock %}
{% block content %}
<p>{% blocktrans trimmed %}Your current phone number is {{ phone }}.
A text message will be sent to validate it.{% endblocktrans %}</p>
<form method="post" class="pk-mark-optional-fields">
{% csrf_token %}
{{ form|with_template }}
<div class="buttons">
<button class="submit-button">{% trans "Validate" %}</button>
<button class="cancel-button" name="cancel" formnovalidate>{% trans "Cancel" %}</button>
</div>
</form>
{% endblock %}

View File

@ -0,0 +1 @@
{% load i18n %}{% blocktrans trimmed with value=code.value %}Your phone validation code is {{ value }}{% endblocktrans %}

View File

@ -62,6 +62,12 @@ accounts_urlpatterns = [
views.phone_change_verify,
name='phone-change-verify',
),
path('validate-phone/', views.phone_validate, name='phone-validate'),
re_path(
'validate-phone/verify/(?P<token>[A-Za-z0-9_ -]+)/$',
views.phone_validate_verify,
name='phone-validate-verify',
),
path(
'consents/',
login_required(views.consents),

View File

@ -172,3 +172,17 @@ def send_phone_change_sms(phone_number, ou, user=None, template_names=None, cont
kind=SMSCode.KIND_PHONE_CHANGE,
**kwargs,
)
def send_phone_validate_sms(phone_number, ou, user=None, template_names=None, context=None, **kwargs):
from authentic2.models import SMSCode
return send_sms(
phone_number,
ou,
user=user,
template_names=template_names or ['phone_validate/sms_code_phone_validate.txt'],
context=context,
kind=SMSCode.KIND_PHONE_VALIDATE,
**kwargs,
)

View File

@ -290,6 +290,7 @@ email_change = decorators.setting_enabled('A2_PROFILE_CAN_CHANGE_EMAIL')(
class PhoneChangeView(HomeURLMixin, IdentifierChangeMixin, cbv.TemplateNamesMixin, FormView):
template_name = 'authentic2/change_phone.html'
reauthn_message = _('You must re-authenticate to change your phone number.')
title = _('Phone number change')
action = 'phone-change'
def get_form_class(self):
@ -375,7 +376,7 @@ class PhoneChangeView(HomeURLMixin, IdentifierChangeMixin, cbv.TemplateNamesMixi
form.add_error(
'phone',
_(
'Multiple registration attempts have already been made from this IP address. No further'
'Multiple phone change attempts have already been made from this IP address. No further'
' SMS will be sent for now, try again later.'
),
)
@ -412,6 +413,140 @@ class PhoneChangeView(HomeURLMixin, IdentifierChangeMixin, cbv.TemplateNamesMixi
phone_change = login_required(PhoneChangeView.as_view())
class PhoneValidateView(HomeURLMixin, cbv.TemplateNamesMixin, FormView):
template_name = 'authentic2/validate_phone.html'
title = _('Phone number validation')
action = 'validate-change'
form_class = profile_forms.PhoneValidateForm
def dispatch(self, *args, **kwargs):
self.authenticator = utils_misc.get_password_authenticator()
if not (
self.authenticator.phone_identifier_field
and self.authenticator.phone_identifier_field.user_editable
and not self.authenticator.phone_identifier_field.disabled
):
raise Http404(_('Phone validation is deactivated.'))
if self.request.user.phone_identifier is None:
messages.info(self.request, _('You have not declared a phone number yet. Please declare one.'))
return shortcuts.redirect('phone-change')
if self.request.user.phone_verified_on is not None:
messages.info(self.request, _('Your phone number has already been validated.'))
return shortcuts.redirect('account_management')
return super().dispatch(*args, **kwargs)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['phone'] = self.request.user.phone_identifier
return ctx
def get_initial(self):
initial = super().get_initial()
initial['phone'] = self.request.user.phone_identifier
return initial
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['user'] = self.request.user
return kwargs
def get_success_url(self):
params = {}
if next_url := getattr(self, 'next_url', None):
params[REDIRECT_FIELD_NAME] = next_url
return utils_misc.make_url('input_sms_code', kwargs={'token': self.code.url_token}, params=params)
def post(self, request, *args, **kwargs):
if 'cancel' in request.POST:
return utils_misc.redirect(request, reverse('account_management'))
return super().post(request, *args, **kwargs)
def form_valid(self, form):
phone = form.cleaned_data.get('phone')
self.request.session['phone'] = phone
code_exists = models.SMSCode.objects.filter(
kind=models.SMSCode.KIND_PHONE_VALIDATE, phone=phone, expires__gt=timezone.now()
).exists()
resend_key = 'phone-validate-allow-sms-resend'
if (
app_settings.A2_SMS_CODE_EXISTS_WARNING
Review

Je ne pense pas qu'on souhaite jamais mettre ce setting à False.

Je ne pense pas qu'on souhaite jamais mettre ce setting à False.
and code_exists
and not self.request.session.get(resend_key)
):
self.request.session[resend_key] = True
form.add_error(
'phone',
_(
'An SMS code has already been sent to %s. Click "Validate" again if you really want it to be'
' sent again.'
)
% phone,
)
return self.form_invalid(form)
self.request.session[resend_key] = False
if 'next_url' in form.cleaned_data:
Review

Il y a un mixin pour ça.

Il y a un mixin pour ça.
self.next_url = form.cleaned_data['next_url']
if is_ratelimited(
Review

Je pense qu'on pourrait n'utiliser qu'un seul groupe pour tous les envois de SMS et factoriser cette partie entre les vues si possible:

$ git grep -A10 is_ratelimited | grep 'is_rate\|group' | grep -B1 sms
src/authentic2/views.py:        if is_ratelimited(
src/authentic2/views.py-            group='phone-change-sms',
src/authentic2/views.py:        if is_ratelimited(
src/authentic2/views.py-            group='phone-change-sms',
--
src/authentic2/views.py:            if is_ratelimited(
src/authentic2/views.py-                group='pw-reset-sms',
src/authentic2/views.py:            if is_ratelimited(
src/authentic2/views.py-                group='pw-reset-sms',
src/authentic2/views.py:        if is_ratelimited(
src/authentic2/views.py-            group='registration-sms',
src/authentic2/views.py:        if is_ratelimited(
src/authentic2/views.py-            group='registration-sms',
Je pense qu'on pourrait n'utiliser qu'un seul groupe pour tous les envois de SMS et factoriser cette partie entre les vues si possible: ``` $ git grep -A10 is_ratelimited | grep 'is_rate\|group' | grep -B1 sms src/authentic2/views.py: if is_ratelimited( src/authentic2/views.py- group='phone-change-sms', src/authentic2/views.py: if is_ratelimited( src/authentic2/views.py- group='phone-change-sms', -- src/authentic2/views.py: if is_ratelimited( src/authentic2/views.py- group='pw-reset-sms', src/authentic2/views.py: if is_ratelimited( src/authentic2/views.py- group='pw-reset-sms', src/authentic2/views.py: if is_ratelimited( src/authentic2/views.py- group='registration-sms', src/authentic2/views.py: if is_ratelimited( src/authentic2/views.py- group='registration-sms', ```
self.request,
key=sms_ratelimit_key,
group='phone-change-sms',
rate=self.authenticator.sms_number_ratelimit or None,
increment=True,
):
form.add_error(
'phone',
_(
'Multiple SMSs have already been sent to this number. Further attempts are blocked,'
' try again later.'
),
)
return self.form_invalid(form)
if is_ratelimited(
Review

Pareil ici.

Pareil ici.
self.request,
key='ip',
group='phone-change-sms',
rate=self.authenticator.sms_ip_ratelimit or None,
increment=True,
):
form.add_error(
'phone',
_(
'Multiple phone validation attempts have already been made from this IP address. No further'
' SMS will be sent for now, try again later.'
),
)
return self.form_invalid(form)
try:
self.code = utils_sms.send_phone_validate_sms(
phone,
self.request.user.ou,
user=self.request.user,
)
except utils_sms.SMSError:
messages.error(
self.request,
_(
'Something went wrong while trying to send the SMS code to you. '
'Please contact your administrator and try again later.'
),
)
return utils_misc.redirect(self.request, reverse('auth_homepage'))
Review

On récupère une next_url plus haut on devrait l'utiliser ici.

On récupère une next_url plus haut on devrait l'utiliser ici.
self.request.journal.record(
'user.phone.validate.request',
user=self.request.user,
session=self.request.session,
phone=phone,
)
return super().form_valid(form)
phone_validate = login_required(PhoneValidateView.as_view())
class PhoneChangeVerifyView(TemplateView):
def get(self, request, *args, **kwargs):
token = kwargs['token'].replace(' ', '')
@ -494,6 +629,65 @@ class PhoneChangeVerifyView(TemplateView):
phone_change_verify = PhoneChangeVerifyView.as_view()
class PhoneValidateVerifyView(TemplateView):
def get(self, request, *args, **kwargs):
token = kwargs['token'].replace(' ', '')
if not token:
return shortcuts.redirect('phone-validate')
try:
token = models.Token.objects.get(
uuid=token,
kind='phone-validate',
)
user_pk = token.content['user']
phone = token.content['phone']
user = User.objects.get(pk=user_pk)
except (models.Token.DoesNotExist, models.Token.MultipleObjectsReturned, ValueError):
messages.error(request, _('Your phone number validation request is invalid, try again'))
return shortcuts.redirect('phone-validate')
except User.DoesNotExist:
messages.error(
request, _('Your phone number validation request relates to an unknown user, try again')
)
return shortcuts.redirect('phone-change')
try:
with atomic():
Lock.lock_identifier(phone)
user.phone_verified_on = timezone.now()
user.save(
update_fields=[
'phone_verified_on',
]
)
token.delete()
except Lock.Error:
messages.error(
request,
_(
'Something went wrong while validating your phone number. Try again later or contact your platform administrator.'
),
)
return shortcuts.redirect('phone-change')
except ValidationError as e:
messages.error(request, e.message)
return shortcuts.redirect('phone-change')
else:
messages.info(request, _('Your phone number is now validated').format(phone))
logger.info('user %s validated its phone number "%s"', user, phone)
hooks.call_hooks('event', name='validate-phone-confirm', user=user, phone=phone)
request.journal.record(
'user.phone.validate',
user=user,
session=request.session,
phone=phone,
)
return shortcuts.redirect('account_management')
phone_validate_verify = PhoneValidateVerifyView.as_view()
class EmailChangeVerifyView(TemplateView):
def get(self, request, *args, **kwargs):
if 'token' in request.GET:
@ -828,6 +1022,11 @@ class ProfileView(HomeURLMixin, cbv.TemplateNamesMixin, TemplateView):
and authenticator.phone_identifier_field.user_editable
and not authenticator.phone_identifier_field.disabled
)
allow_phone_validate = bool(
allow_phone_change
and self.request.user.phone_identifier
and not self.request.user.phone_verified_on
)
context.update(
{
'frontends_block': blocks,
@ -838,6 +1037,7 @@ class ProfileView(HomeURLMixin, cbv.TemplateNamesMixin, TemplateView):
'allow_profile_edit': EditProfile.can_edit_profile(),
'allow_email_change': app_settings.A2_PROFILE_CAN_CHANGE_EMAIL,
'allow_phone_change': allow_phone_change,
'allow_phone_validate': allow_phone_validate,
'allow_authorization_management': False,
# TODO: deprecated should be removed when publik-base-theme is updated
'allow_password_change': utils_misc.user_can_change_password(request=request),
@ -1688,6 +1888,15 @@ class InputSMSCodeView(cbv.ValidateCSRFMixin, FormView):
),
params=params,
)
elif self.code.kind == models.SMSCode.KIND_PHONE_VALIDATE:
return utils_misc.redirect(
self.request,
reverse(
'phone-validate-verify',
kwargs={'token': token.uuid},
),
params=params,
)
input_sms_code = InputSMSCodeView.as_view()

View File

@ -0,0 +1,387 @@
# authentic2 - versatile identity manager
# Copyright (C) 2010-2023 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# 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/>.
from httmock import HTTMock, remember_called, urlmatch
from authentic2.models import Attribute, SMSCode, Token
from .utils import login
@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_validate_phone(app, db, nomail_user, user_ou1, phone_activated_authn, settings):
Attribute.objects.get_or_create(
name='another_phone',
kind='phone_number',
defaults={'label': 'Another phone'},
)
nomail_user.attributes.phone = '+33122446688'
nomail_user.attributes.another_phone = '+33122444444'
nomail_user.phone = ''
nomail_user.phone_verified_on = None
nomail_user.save()
settings.SMS_URL = 'https://foo.whatever.none/'
resp = login(
app,
nomail_user,
login=nomail_user.attributes.phone,
path='/accounts/validate-phone/',
password=nomail_user.username,
)
assert 'Your current phone number is +33122446688.' in resp.text
with HTTMock(sms_service_mock):
resp = resp.form.submit().follow()
code = SMSCode.objects.get()
resp.form.set('sms_code', code.value)
resp = resp.form.submit('').follow()
assert not Token.objects.count()
nomail_user.refresh_from_db()
assert nomail_user.attributes.phone == '+33122446688' # unchanged
assert nomail_user.attributes.another_phone == '+33122444444' # unchanged
assert nomail_user.phone_verified_on is not None
def test_validate_phone_cancel(app, db, nomail_user, user_ou1, phone_activated_authn, settings):
nomail_user.attributes.phone = '+33122446688'
nomail_user.phone = ''
nomail_user.phone_verified_on = None
nomail_user.save()
settings.SMS_URL = 'https://foo.whatever.none/'
resp = login(
app,
nomail_user,
login=nomail_user.attributes.phone,
path='/accounts/validate-phone/',
password=nomail_user.username,
)
assert 'Your current phone number is +33122446688.' in resp.text
resp = resp.form.submit('cancel')
assert resp.status_code == 302
assert resp.location == '/accounts/'
assert not SMSCode.objects.all()
assert not Token.objects.all()
nomail_user.refresh_from_db()
assert nomail_user.phone_verified_on is None
def test_validate_phone_sms_input_cancel(app, db, nomail_user, user_ou1, phone_activated_authn, settings):
nomail_user.attributes.phone = '+33122446688'
nomail_user.phone = ''
nomail_user.phone_verified_on = None
nomail_user.save()
settings.SMS_URL = 'https://foo.whatever.none/'
resp = login(
app,
nomail_user,
login=nomail_user.attributes.phone,
path='/accounts/validate-phone/',
password=nomail_user.username,
)
with HTTMock(sms_service_mock):
resp = resp.form.submit().follow()
resp = resp.form.submit('cancel')
assert resp.status_code == 302
assert resp.location == '/'
assert not Token.objects.count()
assert nomail_user.phone_verified_on is None
def test_validate_phone_wrong_code(app, db, nomail_user, user_ou1, phone_activated_authn, settings):
nomail_user.attributes.phone = '+33122446688'
nomail_user.phone = ''
nomail_user.phone_verified_on = None
nomail_user.save()
settings.SMS_URL = 'https://foo.whatever.none/'
resp = login(
app,
nomail_user,
login=nomail_user.attributes.phone,
path='/accounts/validate-phone/',
password=nomail_user.username,
)
assert 'Your current phone number is +33122446688.' in resp.text
with HTTMock(sms_service_mock):
resp = resp.form.submit().follow()
SMSCode.objects.get()
resp.form.set('sms_code', 'abc')
resp = resp.form.submit('')
assert 'Wrong SMS code.' in resp.pyquery('ul.errorlist li')[0].text
assert not Token.objects.count()
def test_validate_phone_nondefault_attribute(app, db, nomail_user, user_ou1, phone_activated_authn, settings):
another_phone, _ = Attribute.objects.get_or_create(
name='another_phone',
kind='phone_number',
user_editable=True,
disabled=False,
defaults={'label': 'Another phone'},
)
phone_activated_authn.phone_identifier_field = another_phone
phone_activated_authn.save()
nomail_user.attributes.phone = '+33122446688'
nomail_user.attributes.another_phone = '+33122444444'
nomail_user.phone_verified_on = None
nomail_user.save()
settings.SMS_URL = 'https://foo.whatever.none/'
resp = login(
app,
nomail_user,
login=nomail_user.attributes.another_phone,
path='/accounts/validate-phone/',
password=nomail_user.username,
)
assert 'Your current phone number is +33122444444.' in resp.text
with HTTMock(sms_service_mock):
resp = resp.form.submit().follow()
code = SMSCode.objects.get()
resp.form.set('sms_code', code.value)
resp = resp.form.submit('').follow()
assert not Token.objects.count()
nomail_user.refresh_from_db()
assert nomail_user.phone_verified_on is not None
def test_validate_phone_expired_code(app, nomail_user, user_ou1, phone_activated_authn, settings, freezer):
nomail_user.attributes.phone = '+33122446688'
nomail_user.phone_verified_on = None
nomail_user.save()
settings.SMS_URL = 'https://foo.whatever.none/'
resp = login(
app,
nomail_user,
login=nomail_user.attributes.phone,
path='/accounts/validate-phone/',
password=nomail_user.username,
)
with HTTMock(sms_service_mock):
resp = resp.form.submit().follow()
code = SMSCode.objects.get()
resp.form.set('sms_code', code.value)
freezer.tick(3600) # user did not immediately submit code
resp = resp.form.submit('')
assert resp.pyquery('ul.errorlist li')[0].text == 'The code has expired.'
assert not Token.objects.count()
def test_validate_phone_code_modified(app, nomail_user, user_ou1, phone_activated_authn, settings):
nomail_user.attributes.phone = '+33122446688'
nomail_user.phone_verified_on = None
nomail_user.save()
settings.SMS_URL = 'https://foo.whatever.none/'
resp = login(
app,
nomail_user,
login=nomail_user.attributes.phone,
path='/accounts/validate-phone/',
password=nomail_user.username,
)
with HTTMock(sms_service_mock):
resp = resp.form.submit()
location = resp.location[:-5] + 'abcd/' # oops, something went wrong with the url token
app.get(location, status=400)
assert not Token.objects.count()
def test_validate_phone_token_modified(app, nomail_user, user_ou1, phone_activated_authn, settings):
nomail_user.attributes.phone = '+33122446688'
nomail_user.phone_verified_on = None
nomail_user.save()
settings.SMS_URL = 'https://foo.whatever.none/'
resp = login(
app,
nomail_user,
login=nomail_user.attributes.phone,
path='/accounts/validate-phone/',
password=nomail_user.username,
)
with HTTMock(sms_service_mock):
resp = resp.form.submit().follow()
code = SMSCode.objects.get()
resp.form.set('sms_code', code.value)
resp = resp.form.submit('')
resp.location = resp.location.split('?')[0]
resp.location = resp.location[:-5] + 'abcd/' # oops, something went wrong with the url token
resp = resp.follow().maybe_follow()
assert resp.pyquery('.error')[0].text == 'Your phone number validation request is invalid, try again'
nomail_user.refresh_from_db()
assert not nomail_user.phone_verified_on
def test_validate_phone_identifier_attribute_changed(
app, nomail_user, user_ou1, phone_activated_authn, settings
):
phone, dummy = Attribute.objects.get_or_create(
name='another_phone',
kind='phone_number',
defaults={'label': 'Another phone'},
)
nomail_user.attributes.phone = '+33122446688'
nomail_user.attributes.another_phone = '+33122444444'
nomail_user.save()
settings.SMS_URL = 'https://foo.whatever.none/'
resp = login(
app,
nomail_user,
login=nomail_user.attributes.phone,
path='/accounts/validate-phone/',
password=nomail_user.username,
)
assert 'Your current phone number is +33122446688.' in resp.text
with HTTMock(sms_service_mock):
resp = resp.form.submit().follow()
phone_activated_authn.phone_identifier_field = phone
phone_activated_authn.save()
code = SMSCode.objects.get()
resp.form.set('sms_code', code.value)
resp = resp.form.submit('').follow()
assert not Token.objects.count()
nomail_user.refresh_from_db()
assert nomail_user.attributes.phone == '+33122446688' # unchanged
assert nomail_user.attributes.another_phone == '+33122444444' # unchanged
assert nomail_user.phone_verified_on
def test_validate_phone_identifier_field_unknown(app, nomail_user, user_ou1, phone_activated_authn, settings):
nomail_user.attributes.phone = '+33122446688'
nomail_user.phone_verified_on = None
nomail_user.save()
settings.SMS_URL = 'https://foo.whatever.none/'
login(
app,
nomail_user,
login=nomail_user.attributes.phone,
path='/accounts/',
password=nomail_user.username,
)
phone_activated_authn.phone_identifier_field = None
phone_activated_authn.save()
app.get('/accounts/validate-phone/', status=404)
def test_validate_phone_identifier_field_not_user_editable(
app, nomail_user, user_ou1, phone_activated_authn, settings
):
nomail_user.attributes.phone = '+33122446688'
nomail_user.phone_verified_on = None
nomail_user.save()
settings.SMS_URL = 'https://foo.whatever.none/'
login(
app,
nomail_user,
login=nomail_user.attributes.phone,
path='/accounts/',
password=nomail_user.username,
)
phone_activated_authn.phone_identifier_field.user_editable = False
phone_activated_authn.phone_identifier_field.save()
app.get('/accounts/validate-phone/', status=404)
def test_validate_phone_identifier_field_disabled(
app, nomail_user, user_ou1, phone_activated_authn, settings
):
nomail_user.attributes.phone = '+33122446688'
nomail_user.phone_verified_on = None
nomail_user.save()
settings.SMS_URL = 'https://foo.whatever.none/'
login(
app,
nomail_user,
login=nomail_user.attributes.phone,
path='/accounts/',
password=nomail_user.username,
)
phone_activated_authn.phone_identifier_field.disabled = True
phone_activated_authn.phone_identifier_field.save()
app.get('/accounts/validate-phone/', status=404)
def test_validate_change_lock_identifier_error_token_use(
app, nomail_user, user_ou1, phone_activated_authn, settings, monkeypatch
):
from authentic2.models import Lock
nomail_user.attributes.phone = '+33122446688'
nomail_user.phone_verified_on = None
nomail_user.save()
settings.SMS_URL = 'https://foo.whatever.none/'
def erroneous_lock_identifier(identifier, nowait=False):
raise Lock.Error
resp = login(
app,
nomail_user,
login=nomail_user.attributes.phone,
path='/accounts/validate-phone/',
password=nomail_user.username,
)
with HTTMock(sms_service_mock):
resp = resp.form.submit().follow()
code = SMSCode.objects.get()
resp.form.set('sms_code', code.value)
resp = resp.form.submit('')
monkeypatch.setattr(Lock, 'lock_identifier', erroneous_lock_identifier)
resp = resp.follow().maybe_follow()
assert 'Something went wrong while validating' in resp.pyquery('li.error')[0].text
nomail_user.refresh_from_db()
assert not nomail_user.phone_verified_on

View File

@ -494,6 +494,50 @@ def test_delete_account_phone_identifier_changed_in_between(
assert User.objects.get(id=nomail_user_id)
def test_validate_phone_link_displayed(app, nomail_user, settings, phone_activated_authn):
settings.SMS_URL = 'https://foo.whatever.none/'
nomail_user.attributes.phone = '+33122446666'
nomail_user.phone_verified_on = now()
nomail_user.save()
login(app, nomail_user, login=nomail_user.attributes.phone, path='/', password=nomail_user.username)
resp = app.get('/accounts/')
assert not resp.pyquery('a[href="/accounts/validate-phone/"]')
resp = app.get('/accounts/validate-phone/')
assert resp.status_code == 302
assert resp.location == '/accounts/'
resp = resp.follow()
assert 'Your phone number has already been validated.' in resp.pyquery('.info')[0].text
nomail_user.phone_verified_on = None
nomail_user.attributes.phone = None
nomail_user.save()
resp = app.get('/accounts/')
assert not resp.pyquery('a[href="/accounts/validate-phone/"]')
resp = app.get('/accounts/validate-phone/')
assert resp.status_code == 302
assert resp.location == '/accounts/change-phone/'
resp = resp.follow()
assert 'You have not declared a phone number yet. Please declare one.' in resp.pyquery('.info')[0].text
nomail_user.phone_verified_on = None
nomail_user.attributes.phone = '+33122446666'
nomail_user.save()
resp = app.get('/accounts/')
assert resp.pyquery('a[href="/accounts/validate-phone/"]')
resp = app.get('/accounts/validate-phone/')
assert 'Phone number validation' in resp.pyquery('title')[0].text
assert 'Your current phone number is +33122446666.' in resp.text
phone_activated_authn.phone_identifier_field = None
phone_activated_authn.save()
resp = app.get('/accounts/')
assert not resp.pyquery('a[href="/accounts/validate-phone/"]')
app.get('/accounts/validate-phone/', status=404)
def test_login_invalid_next(app):
app.get(reverse('auth_login') + '?next=plop')
@ -507,31 +551,40 @@ def test_custom_account(settings, app, simple_user):
assert response['Location'] == settings.A2_ACCOUNTS_URL
@pytest.mark.parametrize('view_name', ['registration_register', 'password_reset', 'phone-change'])
@pytest.mark.parametrize(
'view_name', ['registration_register', 'password_reset', 'phone-change', 'phone-validate']
)
def test_views_sms_ratelimit(app, db, simple_user, settings, freezer, view_name, phone_activated_authn):
freezer.move_to('2020-01-01')
LoginPasswordAuthenticator.objects.update(sms_ip_ratelimit='10/h', sms_number_ratelimit='3/d')
settings.SMS_SENDER = 'EO'
settings.SMS_URL = 'https://www.example.com/send'
simple_user.attributes.phone = '+33611223344'
simple_user.save()
@urlmatch(scheme='https', netloc='www.example.com', path='/send')
def sms_endpoint_response(url, request):
return httmock_response(200, {})
with HTTMock(sms_endpoint_response):
if view_name in ('phone-change',):
if view_name in (
'phone-change',
'phone-validate',
):
login(app, simple_user)
response = app.get(reverse(view_name))
response.form.set('phone_0', '33')
response.form.set('phone_1', '0612345678')
if view_name not in ('phone-validate',):
response.form.set('phone_0', '33')
response.form.set('phone_1', '0612345678')
if view_name in ('phone-change',):
response.form.set('password', simple_user.username)
response = response.form.submit()
assert 'try again later' not in response.text
for _ in range(2):
response = app.get(reverse(view_name))
response.form.set('phone_0', '33')
response.form.set('phone_1', '0612345678')
if view_name not in ('phone-validate',):
response.form.set('phone_0', '33')
response.form.set('phone_1', '0612345678')
if view_name in ('phone-change',):
response.form.set('password', simple_user.username)
response = response.form.submit()
@ -542,8 +595,9 @@ def test_views_sms_ratelimit(app, db, simple_user, settings, freezer, view_name,
assert 'try again later' not in response.text
response = app.get(reverse(view_name))
response.form.set('phone_0', '33')
response.form.set('phone_1', '0612345678')
if view_name not in ('phone-validate',):
response.form.set('phone_0', '33')
response.form.set('phone_1', '0612345678')
if view_name in ('phone-change',):
response.form.set('password', simple_user.username)
response = response.form.submit()
@ -552,8 +606,29 @@ def test_views_sms_ratelimit(app, db, simple_user, settings, freezer, view_name,
response = response.form.submit()
assert 'try again later' in response.text
# reach ip limit
for _ in range(7):
if view_name not in ('phone-validate',): # phone validation does not prompt user for a phone number
# reach ip limit
for _ in range(7):
response = app.get(reverse(view_name))
random_suffix = randint(0, 9999)
response.form.set('phone_0', '33')
response.form.set('phone_1', f'061234{random_suffix:04d}')
if view_name in ('phone-change',):
response.form.set('password', simple_user.username)
response = response.form.submit()
assert 'try again later' not in response.text
response = app.get(reverse(view_name))
random_suffix = randint(0, 9999)
response.form.set('phone_0', '33')
response.form.set('phone_1', f'061234{random_suffix:04d}')
if view_name in ('phone-change',):
response.form.set('password', simple_user.username)
response = response.form.submit()
assert 'try again later' in response.text
# ip ratelimits are lifted after an hour
freezer.tick(datetime.timedelta(hours=1))
response = app.get(reverse(view_name))
random_suffix = randint(0, 9999)
response.form.set('phone_0', '33')
@ -563,47 +638,28 @@ def test_views_sms_ratelimit(app, db, simple_user, settings, freezer, view_name,
response = response.form.submit()
assert 'try again later' not in response.text
response = app.get(reverse(view_name))
random_suffix = randint(0, 9999)
response.form.set('phone_0', '33')
response.form.set('phone_1', f'061234{random_suffix:04d}')
if view_name in ('phone-change',):
response.form.set('password', simple_user.username)
response = response.form.submit()
assert 'try again later' in response.text
# identifier ratelimits are lifted after a day
response = app.get(reverse(view_name))
if view_name not in ('phone-validate',):
response.form.set('phone_0', '33')
response.form.set('phone_1', '0612345678')
if view_name in ('phone-change',):
response.form.set('password', simple_user.username)
response = response.form.submit()
assert 'Multiple SMSs have already been sent to this number.' in response.text
if view_name in ('phone-change',):
response.form.set('password', simple_user.username)
response = response.form.submit()
assert 'try again later' in response.text
# ip ratelimits are lifted after an hour
freezer.tick(datetime.timedelta(hours=1))
response = app.get(reverse(view_name))
random_suffix = randint(0, 9999)
response.form.set('phone_0', '33')
response.form.set('phone_1', f'061234{random_suffix:04d}')
if view_name in ('phone-change',):
response.form.set('password', simple_user.username)
response = response.form.submit()
assert 'try again later' not in response.text
# identifier ratelimits are lifted after a day
response = app.get(reverse(view_name))
response.form.set('phone_0', '33')
response.form.set('phone_1', '0612345678')
if view_name in ('phone-change',):
response.form.set('password', simple_user.username)
response = response.form.submit()
assert 'Multiple SMSs have already been sent to this number.' in response.text
if view_name in ('phone-change',):
response.form.set('password', simple_user.username)
response = response.form.submit()
assert 'try again later' in response.text
freezer.tick(datetime.timedelta(days=1))
response = app.get(reverse(view_name))
response.form.set('phone_0', '33')
response.form.set('phone_1', '0612345678')
if view_name in ('phone-change',):
response.form.set('password', simple_user.username)
response = response.form.submit()
assert 'try again later' not in response.text
freezer.tick(datetime.timedelta(days=1))
response = app.get(reverse(view_name))
response.form.set('phone_0', '33')
response.form.set('phone_1', '0612345678')
if view_name in ('phone-change',):
response.form.set('password', simple_user.username)
response = response.form.submit()
assert 'try again later' not in response.text
@pytest.mark.parametrize('view_name', ['registration_register', 'password_reset'])