diff --git a/src/authentic2/forms/passwords.py b/src/authentic2/forms/passwords.py index 2512c8b68..01a0aabd5 100644 --- a/src/authentic2/forms/passwords.py +++ b/src/authentic2/forms/passwords.py @@ -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): diff --git a/src/authentic2/forms/registration.py b/src/authentic2/forms/registration.py index 0e71cf2d5..eaf2438a2 100644 --- a/src/authentic2/forms/registration.py +++ b/src/authentic2/forms/registration.py @@ -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, ) diff --git a/src/authentic2/migrations/0045_auto_20230117_1513.py b/src/authentic2/migrations/0045_auto_20230117_1513.py new file mode 100644 index 000000000..89e45716e --- /dev/null +++ b/src/authentic2/migrations/0045_auto_20230117_1513.py @@ -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', + ), + ), + ] diff --git a/src/authentic2/models.py b/src/authentic2/models.py index 60e16252b..99c2b07ca 100644 --- a/src/authentic2/models.py +++ b/src/authentic2/models.py @@ -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): diff --git a/src/authentic2/templates/password_lost/sms_code_password_lost.txt b/src/authentic2/templates/password_lost/sms_code_password_lost.txt new file mode 100644 index 000000000..f16b234ae --- /dev/null +++ b/src/authentic2/templates/password_lost/sms_code_password_lost.txt @@ -0,0 +1 @@ +{% load i18n %}{% blocktrans trimmed with value=code.value %}Your code is {{ value }}{% endblocktrans %} \ No newline at end of file diff --git a/src/authentic2/templates/registration/sms_input_registration_code.html b/src/authentic2/templates/registration/sms_input_code.html similarity index 87% rename from src/authentic2/templates/registration/sms_input_registration_code.html rename to src/authentic2/templates/registration/sms_input_code.html index 55b12e157..eee90004c 100644 --- a/src/authentic2/templates/registration/sms_input_registration_code.html +++ b/src/authentic2/templates/registration/sms_input_code.html @@ -7,7 +7,7 @@ {% block content %}