page “Mon compte” : action de validation d’un numéro de téléphone existant (#82388) #152
|
@ -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.'),
|
||||
|
|
|
@ -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'
|
||||
|
||||
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
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
|
@ -0,0 +1 @@
|
|||
{% load i18n %}{% blocktrans trimmed with value=code.value %}Your phone validation code is {{ value }}{% endblocktrans %}
|
|
@ -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),
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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
|
||||
bdauvergne
commented
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:
|
||||
bdauvergne
commented
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(
|
||||
bdauvergne
commented
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:
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(
|
||||
bdauvergne
commented
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'))
|
||||
bdauvergne
commented
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()
|
||||
|
|
|
@ -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
|
|
@ -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'])
|
||||
|
|
Loading…
Reference in New Issue
Est-ce qu'on pourrait réutiliser phone-change plutôt ? La situation me parait de loin identique.