custom_user : récupération du compte par envoi d’un code au numéro vérifié lorsque le mot de passe a été oublié par l’usager (#69890) #31
|
@ -20,6 +20,7 @@ from collections import OrderedDict
|
|||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import forms as auth_forms
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.forms import Form
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
@ -32,7 +33,8 @@ from .. import app_settings, models, validators
|
|||
from ..backends import get_user_queryset
|
||||
from ..utils import hooks
|
||||
from ..utils import misc as utils_misc
|
||||
from .fields import CheckPasswordField, NewPasswordField, PasswordField, ValidatedEmailField
|
||||
from ..utils import sms as utils_sms
|
||||
from .fields import CheckPasswordField, NewPasswordField, PasswordField, PhoneField, ValidatedEmailField
|
||||
from .honeypot import HoneypotForm
|
||||
from .utils import NextUrlFormMixin
|
||||
|
||||
|
@ -42,14 +44,29 @@ logger = logging.getLogger(__name__)
|
|||
class PasswordResetForm(HoneypotForm):
|
||||
next_url = forms.CharField(widget=forms.HiddenInput, required=False)
|
||||
|
||||
email = ValidatedEmailField(label=_("Email"))
|
||||
email = ValidatedEmailField(label=_("Email"), required=False)
|
||||
|
||||
phone = PhoneField(
|
||||
label=_('Phone number'),
|
||||
help_text=_('Your mobile phone number.'),
|
||||
required=False,
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.users = []
|
||||
if app_settings.A2_USER_CAN_RESET_PASSWORD_BY_USERNAME:
|
||||
del self.fields['email']
|
||||
self.fields['email_or_username'] = forms.CharField(label=_('Email or username'), max_length=254)
|
||||
self.fields['email_or_username'] = forms.CharField(
|
||||
label=_('Email or username'), max_length=254, required=False
|
||||
)
|
||||
|
||||
if not app_settings.A2_ACCEPT_PHONE_AUTHENTICATION or not get_user_model()._meta.get_field('phone'):
|
||||
del self.fields['phone']
|
||||
if 'email' in self.fields:
|
||||
self.fields['email'].required = True
|
||||
else:
|
||||
self.fields['email_or_username'].required = True
|
||||
|
||||
def clean_email(self):
|
||||
email = self.cleaned_data.get('email')
|
||||
|
@ -71,25 +88,44 @@ class PasswordResetForm(HoneypotForm):
|
|||
self.cleaned_data['email'] = email_or_username
|
||||
return email_or_username
|
||||
|
||||
def clean_phone(self):
|
||||
phone = self.cleaned_data.get('phone')
|
||||
if phone:
|
||||
self.users = get_user_queryset().filter(phone=phone)
|
||||
return phone
|
||||
|
||||
def clean(self):
|
||||
if self.users and not any(user.email for user in self.users):
|
||||
if app_settings.A2_ACCEPT_PHONE_AUTHENTICATION and get_user_model()._meta.get_field('phone'):
|
||||
if (
|
||||
not self.cleaned_data['email']
|
||||
and not self.cleaned_data.get('email_or_username')
|
||||
and not self.cleaned_data['phone']
|
||||
):
|
||||
raise ValidationError(_('Please provide an email address or a mobile phone number.'))
|
||||
elif self.users and not any(user.email for user in self.users):
|
||||
raise ValidationError(_('Your account has no email, you cannot ask for a password reset.'))
|
||||
return self.cleaned_data
|
||||
|
||||
def save(self):
|
||||
"""
|
||||
Generates a one-use only link for resetting password and sends to the
|
||||
user.
|
||||
Generates either:
|
||||
· a one-use only link for resetting password and sends to the user.
|
||||
· a code sent by SMS which the user needs to input in order to confirm password reset.
|
||||
"""
|
||||
email = self.cleaned_data.get('email')
|
||||
email_or_username = self.cleaned_data.get('email_or_username')
|
||||
phone = self.cleaned_data.get('phone')
|
||||
|
||||
active_users = self.users.filter(is_active=True)
|
||||
email_sent = False
|
||||
sms_sent = False
|
||||
|
||||
for user in active_users:
|
||||
if not user.email:
|
||||
logger.info('password reset failed for account "%r": account has no email', user)
|
||||
if not user.email and not user.phone:
|
||||
logger.info(
|
||||
'password reset failed for account "%r": account has no email nor mobile phone number',
|
||||
user,
|
||||
)
|
||||
continue
|
||||
|
||||
if user.userexternalid_set.exists():
|
||||
|
@ -116,15 +152,34 @@ class PasswordResetForm(HoneypotForm):
|
|||
# we don't set the password to a random string, as some users should not have
|
||||
# a password
|
||||
set_random_password = user.has_usable_password() and app_settings.A2_SET_RANDOM_PASSWORD_ON_RESET
|
||||
email_sent = True
|
||||
utils_misc.send_password_reset_mail(
|
||||
user, set_random_password=set_random_password, next_url=self.cleaned_data.get('next_url')
|
||||
)
|
||||
journal.record('user.password.reset.request', email=user.email, user=user)
|
||||
if email or email_or_username:
|
||||
email_sent = True
|
||||
utils_misc.send_password_reset_mail(
|
||||
user, set_random_password=set_random_password, next_url=self.cleaned_data.get('next_url')
|
||||
)
|
||||
elif phone:
|
||||
try:
|
||||
sms_sent = True
|
||||
code = utils_sms.send_password_reset_sms(
|
||||
phone,
|
||||
user.ou,
|
||||
user=user,
|
||||
)
|
||||
except utils_sms.SMSError:
|
||||
pass
|
||||
else:
|
||||
# all user info sending logic contained here, however the view needs to know
|
||||
# which code was sent:
|
||||
return code
|
||||
|
||||
for user in self.users.filter(is_active=False):
|
||||
logger.info('password reset failed for user "%r": account is disabled', user)
|
||||
email_sent = True
|
||||
utils_misc.send_templated_mail(user, ['authentic2/password_reset_refused'])
|
||||
if email or email_or_username:
|
||||
email_sent = True
|
||||
code = utils_misc.send_templated_mail(user, ['authentic2/password_reset_refused'])
|
||||
elif phone:
|
||||
sms_sent = True
|
||||
if not email_sent and email:
|
||||
logger.info('password reset request for "%s", no user found', email)
|
||||
if getattr(settings, 'REGISTRATION_OPEN', True):
|
||||
|
@ -139,7 +194,20 @@ class PasswordResetForm(HoneypotForm):
|
|||
else:
|
||||
ctx = {}
|
||||
utils_misc.send_templated_mail(email, ['authentic2/password_reset_no_account'], context=ctx)
|
||||
hooks.call_hooks('event', name='password-reset', email=email or email_or_username, users=active_users)
|
||||
hooks.call_hooks(
|
||||
'event', name='password-reset', email=email or email_or_username, users=active_users
|
||||
)
|
||||
elif not email_sent and not sms_sent and phone:
|
||||
try:
|
||||
code = utils_sms.send_password_reset_sms(
|
||||
phone,
|
||||
ou=None,
|
||||
user=None,
|
||||
)
|
||||
except utils_sms.SMSError:
|
||||
pass
|
||||
else:
|
||||
return code
|
||||
|
||||
|
||||
class PasswordResetMixin(Form):
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
# Generated by Django 2.2.26 on 2023-01-17 14:13
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('authentic2', '0047_initialize_services_runtime_settings'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='smscode',
|
||||
name='fake',
|
||||
field=models.BooleanField(default=False, verbose_name='Is a fake code'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='smscode',
|
||||
name='user',
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name='user',
|
||||
),
|
||||
),
|
||||
]
|
|
@ -814,7 +814,11 @@ class APIClient(models.Model):
|
|||
class SMSCode(models.Model):
|
||||
CODE_DURATION = 120
|
||||
KIND_REGISTRATION = 'registration'
|
||||
KIND_PASSWORD_LOST = 'password-lost'
|
||||
KIND_PASSWORD_LOST = 'password-reset'
|
||||
CODE_TO_TOKEN_KINDS = {
|
||||
KIND_REGISTRATION: 'registration',
|
||||
KIND_PASSWORD_LOST: 'pw-reset',
|
||||
}
|
||||
value = models.CharField(
|
||||
verbose_name=_('Identifier'), default=create_sms_code, editable=False, max_length=32
|
||||
)
|
||||
|
@ -822,6 +826,9 @@ class SMSCode(models.Model):
|
|||
phone = models.CharField(
|
||||
_('phone number'), null=True, blank=True, max_length=64, validators=[PhoneNumberValidator]
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, verbose_name=_('user'), on_delete=models.CASCADE, null=True
|
||||
)
|
||||
url_token = models.UUIDField(
|
||||
verbose_name=_('URL token'),
|
||||
default=uuid.uuid4,
|
||||
|
@ -830,19 +837,22 @@ class SMSCode(models.Model):
|
|||
expires = models.DateTimeField(verbose_name=_('Expires'))
|
||||
sent = models.BooleanField(default=False, verbose_name=_('SMS code sent'))
|
||||
|
||||
# fake codes to avoid disclosing account existence info on unjustified password reset attempts
|
||||
fake = models.BooleanField(default=False, verbose_name=_('Is a fake code'))
|
||||
|
||||
@classmethod
|
||||
def cleanup(cls, now=None):
|
||||
now = now or timezone.now()
|
||||
cls.objects.filter(expires__lte=now).delete()
|
||||
|
||||
@classmethod
|
||||
def create(cls, phone, kind=None, expires=None, duration=None):
|
||||
def create(cls, phone, user=None, kind=None, expires=None, fake=False, duration=None):
|
||||
if not kind:
|
||||
kind = cls.KIND_REGISTRATION
|
||||
if not duration:
|
||||
duration = cls.CODE_DURATION
|
||||
expires = expires or (timezone.now() + datetime.timedelta(seconds=duration))
|
||||
return cls.objects.create(kind=kind, phone=phone, expires=expires)
|
||||
return cls.objects.create(kind=kind, user=user, phone=phone, expires=expires, fake=fake)
|
||||
|
||||
|
||||
class Setting(models.Model):
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
{% load i18n %}{% blocktrans trimmed with value=code.value %}Your code is {{ value }}{% endblocktrans %}
|
|
@ -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.
|
|
@ -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(
|
||||
|
|
|
@ -48,12 +48,14 @@ def create_sms_code():
|
|||
)
|
||||
|
||||
|
||||
def generate_code(phone_number):
|
||||
def generate_code(phone_number, user=None, kind=None, fake=False):
|
||||
from authentic2.models import SMSCode
|
||||
|
||||
return SMSCode.create(
|
||||
phone_number,
|
||||
kind='registration',
|
||||
user=user,
|
||||
kind=kind or SMSCode.KIND_REGISTRATION,
|
||||
fake=fake or kind is SMSCode.KIND_PASSWORD_LOST and user is None,
|
||||
)
|
||||
|
||||
|
||||
|
@ -61,7 +63,7 @@ class SMSError(Exception):
|
|||
pass
|
||||
|
||||
|
||||
def send_registration_sms(request, phone_number, ou, template_names=None, context=None, **kwargs):
|
||||
def send_sms(phone_number, ou, user=None, template_names=None, context=None, kind=None, **kwargs):
|
||||
"""Sends a registration code sms to a user, the latter inputs the received code
|
||||
in a dedicated form to validate their account creation.
|
||||
"""
|
||||
|
@ -79,12 +81,12 @@ def send_registration_sms(request, phone_number, ou, template_names=None, contex
|
|||
logger.error('settings.SMS_URL is not set')
|
||||
raise SMSError('SMS improperly configured')
|
||||
|
||||
if not template_names:
|
||||
template_names = ['registration/sms_code_registration.txt']
|
||||
if not isinstance(context, dict):
|
||||
context = {}
|
||||
|
||||
code = generate_code(phone_number)
|
||||
code = generate_code(phone_number, user=user, kind=kind)
|
||||
if code.fake is True:
|
||||
return code
|
||||
context.update({'code': code})
|
||||
|
||||
message = render_plain_text_template_to_string(template_names, context)
|
||||
|
@ -99,9 +101,35 @@ def send_registration_sms(request, phone_number, ou, template_names=None, contex
|
|||
with transaction.atomic():
|
||||
response = requests.post(url, json=payload, timeout=10)
|
||||
response.raise_for_status()
|
||||
code.sent = True
|
||||
code.save()
|
||||
except RequestException as e:
|
||||
logger.warning('sms registration to %s using %s failed: %s', phone_number, url, e)
|
||||
logger.warning('sms code to %s using %s failed: %s', phone_number, url, e)
|
||||
raise SMSError(f'Error while contacting SMS service: {e}')
|
||||
return code
|
||||
|
||||
|
||||
def send_registration_sms(phone_number, ou, template_names=None, context=None, **kwargs):
|
||||
from authentic2.models import SMSCode
|
||||
|
||||
return send_sms(
|
||||
phone_number,
|
||||
ou,
|
||||
template_names=template_names or ['registration/sms_code_registration.txt'],
|
||||
context=context,
|
||||
kind=SMSCode.KIND_REGISTRATION,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def send_password_reset_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 ['password_lost/sms_code_password_lost.txt'],
|
||||
context=context,
|
||||
kind=SMSCode.KIND_PASSWORD_LOST,
|
||||
**kwargs,
|
||||
)
|
||||
|
|
|
@ -848,9 +848,13 @@ class PasswordResetView(FormView):
|
|||
|
||||
form_class = passwords_forms.PasswordResetForm
|
||||
title = _('Password Reset')
|
||||
code = None
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse('password_reset_instructions')
|
||||
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_sms_code', kwargs={'token': self.code.url_token})
|
||||
|
||||
def get_template_names(self):
|
||||
return [
|
||||
|
@ -882,6 +886,7 @@ class PasswordResetView(FormView):
|
|||
)
|
||||
email_field = 'email_or_username' if app_settings.A2_USER_CAN_RESET_PASSWORD_BY_USERNAME else 'email'
|
||||
email = form.cleaned_data.get(email_field)
|
||||
phone = form.cleaned_data.get('phone')
|
||||
|
||||
# if an email has already been sent, warn once before allowing resend
|
||||
token = models.Token.objects.filter(
|
||||
|
@ -901,41 +906,87 @@ class PasswordResetView(FormView):
|
|||
return self.form_invalid(form)
|
||||
self.request.session[resend_key] = False
|
||||
|
||||
if is_ratelimited(
|
||||
self.request,
|
||||
key='post:email',
|
||||
group='pw-reset-email',
|
||||
rate=app_settings.A2_EMAILS_ADDRESS_RATELIMIT,
|
||||
increment=True,
|
||||
):
|
||||
self.request.journal.record('user.password.reset.failure', email=email)
|
||||
form.add_error(
|
||||
email_field,
|
||||
_(
|
||||
'Multiple emails have already been sent to this address. Further attempts are blocked,'
|
||||
' please check your spam folder or try again later.'
|
||||
),
|
||||
)
|
||||
return self.form_invalid(form)
|
||||
if is_ratelimited(
|
||||
self.request,
|
||||
key='ip',
|
||||
group='pw-reset-email',
|
||||
rate=app_settings.A2_EMAILS_IP_RATELIMIT,
|
||||
increment=True,
|
||||
):
|
||||
self.request.journal.record('user.password.reset.failure', email=email)
|
||||
form.add_error(
|
||||
email_field,
|
||||
_(
|
||||
'Multiple password reset attempts have already been made from this IP address. No further'
|
||||
' email will be sent, please check your spam folder or try again later.'
|
||||
),
|
||||
)
|
||||
return self.form_invalid(form)
|
||||
if email:
|
||||
if is_ratelimited(
|
||||
self.request,
|
||||
key='post:email',
|
||||
group='pw-reset-email',
|
||||
rate=app_settings.A2_EMAILS_ADDRESS_RATELIMIT,
|
||||
increment=True,
|
||||
):
|
||||
self.request.journal.record('user.password.reset.failure', email=email)
|
||||
form.add_error(
|
||||
email_field,
|
||||
_(
|
||||
'Multiple emails have already been sent to this address. Further attempts are blocked,'
|
||||
' please check your spam folder or try again later.'
|
||||
),
|
||||
)
|
||||
return self.form_invalid(form)
|
||||
if is_ratelimited(
|
||||
self.request,
|
||||
key='ip',
|
||||
group='pw-reset-email',
|
||||
rate=app_settings.A2_EMAILS_IP_RATELIMIT,
|
||||
increment=True,
|
||||
):
|
||||
self.request.journal.record('user.password.reset.failure', email=email)
|
||||
form.add_error(
|
||||
email_field,
|
||||
_(
|
||||
'Multiple password reset attempts have already been made from this IP address. No further'
|
||||
' email will be sent, please check your spam folder or try again later.'
|
||||
),
|
||||
)
|
||||
return self.form_invalid(form)
|
||||
form.save()
|
||||
|
||||
form.save()
|
||||
self.request.session['reset_email'] = email
|
||||
elif phone:
|
||||
if is_ratelimited(
|
||||
self.request,
|
||||
key=sms_ratelimit_key,
|
||||
group='pw-reset-sms',
|
||||
rate=app_settings.A2_SMS_NUMBER_RATELIMIT,
|
||||
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(
|
||||
self.request,
|
||||
key='ip',
|
||||
group='pw-reset-sms',
|
||||
rate=app_settings.A2_SMS_IP_RATELIMIT,
|
||||
increment=True,
|
||||
):
|
||||
form.add_error(
|
||||
'email',
|
||||
_(
|
||||
'Multiple registration 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)
|
||||
|
||||
self.code = form.save()
|
||||
if not self.code:
|
||||
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'))
|
||||
if email:
|
||||
self.request.session['reset_email'] = email
|
||||
elif phone:
|
||||
self.request.session['reset_phone'] = phone
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
|
@ -1092,7 +1143,7 @@ class BaseRegistrationView(HomeURLMixin, FormView):
|
|||
if email:
|
||||
return self.perform_email_registration(form, email)
|
||||
|
||||
if settings.A2_ACCEPT_PHONE_AUTHENTICATION:
|
||||
if app_settings.A2_ACCEPT_PHONE_AUTHENTICATION:
|
||||
phone = form.cleaned_data.pop('phone')
|
||||
return self.perform_phone_registration(form, phone)
|
||||
|
||||
|
@ -1152,12 +1203,12 @@ class BaseRegistrationView(HomeURLMixin, FormView):
|
|||
)
|
||||
return self.form_invalid(form)
|
||||
try:
|
||||
code = send_registration_sms(self.request, phone, ou=self.ou, **self.token)
|
||||
code = send_registration_sms(phone, ou=self.ou, **self.token)
|
||||
except SMSError:
|
||||
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.'
|
||||
),
|
||||
)
|
||||
|
@ -1166,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},
|
||||
)
|
||||
|
||||
|
@ -1245,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):
|
||||
|
@ -1275,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):
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,12 +1047,12 @@ 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 (
|
||||
caplog.records[0].message
|
||||
== 'sms registration to +33612345678 using https://foo.whatever.none/ failed: unreachable'
|
||||
== 'sms code to +33612345678 using https://foo.whatever.none/ failed: unreachable'
|
||||
)
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
|
Loading…
Reference in New Issue