diff --git a/modoboa/admin/api.py b/modoboa/admin/api.py index dd74ecf10..2c7ee274c 100644 --- a/modoboa/admin/api.py +++ b/modoboa/admin/api.py @@ -2,6 +2,7 @@ from django import http from django.contrib.contenttypes.models import ContentType +from django.utils.translation import ugettext as _ from rest_framework import filters, status, viewsets from rest_framework.decorators import action @@ -10,7 +11,9 @@ from rest_framework.permissions import DjangoModelPermissions, IsAuthenticated from rest_framework.response import Response from modoboa.core import models as core_models -from . import models, serializers +from modoboa.core import sms_backends + +from . import lib, models, serializers class DomainViewSet(viewsets.ModelViewSet): @@ -65,7 +68,8 @@ class AccountViewSet(viewsets.ModelViewSet): action_dict = { "list": serializers.AccountSerializer, "retrieve": serializers.AccountSerializer, - "password": serializers.AccountPasswordSerializer + "password": serializers.AccountPasswordSerializer, + "reset_password": serializers.ResetPasswordSerializer, } return action_dict.get( self.action, serializers.WritableAccountSerializer) @@ -115,6 +119,37 @@ class AccountViewSet(viewsets.ModelViewSet): serializer = serializers.AccountExistsSerializer(data) return Response(serializer.data) + @action(methods=["post"], detail=False) + def reset_password(self, request): + """Reset account password and send a new one by SMS.""" + sms_password_recovery = ( + request.localconfig.parameters + .get_value("sms_password_recovery", app="core") + ) + if not sms_password_recovery: + return Response(status=404) + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = core_models.User.objects.filter( + email=serializer.validated_data["email"]).first() + if not user or not user.phone_number: + return Response(status=404) + backend = sms_backends.get_active_backend( + request.localconfig.parameters) + if not backend: + return Response(status=404) + password = lib.make_password() + content = _("Here is your new Modoboa password: {}").format( + password) + if not backend.send(content, [str(user.phone_number)]): + body = {"status": "ko"} + else: + # SMS was sent, now we can set the new password. + body = {"status": "ok"} + user.set_password(password) + user.save(update_fields=["password"]) + return Response(body) + class AliasViewSet(viewsets.ModelViewSet): """ diff --git a/modoboa/admin/serializers.py b/modoboa/admin/serializers.py index c91382c9a..b46723097 100644 --- a/modoboa/admin/serializers.py +++ b/modoboa/admin/serializers.py @@ -464,3 +464,9 @@ class SenderAddressSerializer(serializers.ModelSerializer): raise serializers.ValidationError( _("You don't have access to this mailbox.")) return value + + +class ResetPasswordSerializer(serializers.Serializer): + """Serializer by the reset password endpoint.""" + + email = lib_fields.DRFEmailFieldUTF8() diff --git a/modoboa/admin/templates/admin/messages_log.html b/modoboa/admin/templates/admin/messages_log.html new file mode 100644 index 000000000..9e20ef518 --- /dev/null +++ b/modoboa/admin/templates/admin/messages_log.html @@ -0,0 +1,11 @@ +{% for message in messages %} + + {{ message.id }} + {{ message.date }} + {{ message.from }} + {{ message.to }} + + {{ message.status }} + + +{% endfor %} diff --git a/modoboa/admin/templates/admin/messages_log_headers.html b/modoboa/admin/templates/admin/messages_log_headers.html new file mode 100644 index 000000000..e0ff04b58 --- /dev/null +++ b/modoboa/admin/templates/admin/messages_log_headers.html @@ -0,0 +1,6 @@ +{% load i18n %} +{% trans "ID" %} +{% trans "Date" %} +{% trans "From" %} +{% trans "To" %} +{% trans "Status" %} diff --git a/modoboa/core/app_settings.py b/modoboa/core/app_settings.py index 339844cd8..ee9a52e15 100644 --- a/modoboa/core/app_settings.py +++ b/modoboa/core/app_settings.py @@ -1,3 +1,7 @@ +"""Core settings.""" + +from collections import OrderedDict + from django import forms from django.conf import settings from django.contrib.auth import password_validation @@ -10,7 +14,9 @@ from modoboa.lib.form_utils import ( HorizontalRadioSelect, SeparatorField, YesNoField ) from modoboa.parameters import forms as param_forms, tools as param_tools + from . import constants +from . import sms_backends def enabled_applications(): @@ -94,6 +100,35 @@ class GeneralParametersForm(param_forms.AdminParametersForm): ) ) + password_recovery_msg = forms.CharField( + label=ugettext_lazy("Password recovery announcement"), + initial="", + required=False, + widget=forms.widgets.Textarea(), + help_text=ugettext_lazy( + "A temporary message that will be displayed on the " + "reset password page." + ) + ) + + sms_password_recovery = YesNoField( + label=ugettext_lazy("Enable password recovery by SMS"), + initial=False, + help_text=ugettext_lazy( + "Enable password recovery by SMS for users who filled " + "a phone number." + ) + ) + + sms_provider = forms.ChoiceField( + label=ugettext_lazy("SMS provider"), + choices=constants.SMS_BACKENDS, + help_text=ugettext_lazy( + "Choose a provider to sens password recovery SMS" + ), + required=False + ) + # LDAP specific settings ldap_sep = SeparatorField(label=ugettext_lazy("LDAP settings")) @@ -463,12 +498,27 @@ class GeneralParametersForm(param_forms.AdminParametersForm): "check_new_versions": "enable_api_communication=True", "send_statistics": "enable_api_communication=True", "send_new_versions_email": "check_new_versions=True", - "new_versions_email_rcpt": "check_new_versions=True" + "new_versions_email_rcpt": "check_new_versions=True", + "sms_provider": "sms_password_recovery=True", } def __init__(self, *args, **kwargs): super(GeneralParametersForm, self).__init__(*args, **kwargs) self.fields["default_top_redirection"].choices = enabled_applications() + self._add_visibilty_rules( + sms_backends.get_all_backend_visibility_rules() + ) + + def _add_dynamic_fields(self): + new_fields = OrderedDict() + for field, value in self.fields.items(): + new_fields[field] = value + if field == "sms_provider": + sms_backend_fields = sms_backends.get_all_backend_settings() + for field, definition in sms_backend_fields.items(): + new_fields[field] = definition["type"]( + **definition["attrs"]) + self.fields = new_fields def clean_ldap_user_dn_template(self): tpl = self.cleaned_data["ldap_user_dn_template"] @@ -512,7 +562,7 @@ class GeneralParametersForm(param_forms.AdminParametersForm): Depending on 'ldap_auth_method' value, we check for different required parameters. """ - super(GeneralParametersForm, self).clean() + super().clean() cleaned_data = self.cleaned_data if cleaned_data["authentication_type"] != "ldap": return cleaned_data @@ -526,6 +576,17 @@ class GeneralParametersForm(param_forms.AdminParametersForm): if f not in cleaned_data or cleaned_data[f] == u'': self.add_error(f, _("This field is required")) + if cleaned_data["sms_password_recovery"]: + provider = cleaned_data.get("sms_provider") + if provider: + sms_settings = sms_backends.get_backend_settings(provider) + if sms_settings: + for name in sms_settings.keys(): + if not cleaned_data.get(name): + self.add_error(name, _("This field is required")) + else: + self.add_error("sms_provider", _("This field is required")) + return cleaned_data def _apply_ldap_settings(self, values, backend): diff --git a/modoboa/core/commands/templates/settings.py.tpl b/modoboa/core/commands/templates/settings.py.tpl index ae49bbe42..462284a0f 100644 --- a/modoboa/core/commands/templates/settings.py.tpl +++ b/modoboa/core/commands/templates/settings.py.tpl @@ -64,6 +64,7 @@ INSTALLED_APPS = ( 'ckeditor_uploader', 'rest_framework', 'rest_framework.authtoken', + 'phonenumber_field', {% if devmode %} 'djangobower',{% endif %} ) @@ -191,6 +192,8 @@ REST_FRAMEWORK = { MODOBOA_API_URL = 'https://api.modoboa.org/1/' +DISABLE_DASHBOARD_EXTERNAL_QUERIES = False + # Password validation # https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators @@ -297,7 +300,7 @@ SILENCED_SYSTEM_CHECKS = [ "security.W019", # modoboa uses iframes to display e-mails ] -DISABLE_DASHBOARD_EXTERNAL_QUERIES = False +PHONENUMBER_DB_FORMAT = 'INTERNATIONAL' # Load settings from extensions {% for extension in extra_settings %} diff --git a/modoboa/core/constants.py b/modoboa/core/constants.py index 99a0b0b2b..e87f70b52 100644 --- a/modoboa/core/constants.py +++ b/modoboa/core/constants.py @@ -1,5 +1,6 @@ """Core application constants.""" +from django.conf import settings from django.utils.translation import ugettext_lazy SIMPLEUSERS_ROLE = ("SimpleUsers", ugettext_lazy("Simple user")) @@ -96,3 +97,11 @@ PERMISSIONS = { ["admin", "domainalias", "delete_domainalias"], ] } + +SMS_BACKENDS = [ + ("", ugettext_lazy("Choose a provider")), + ("ovh", "OVH"), +] + +if settings.DEBUG: + SMS_BACKENDS.insert(1, ("dummy", ugettext_lazy("Dummy"))) diff --git a/modoboa/core/forms.py b/modoboa/core/forms.py index 74e0150bc..96873a861 100644 --- a/modoboa/core/forms.py +++ b/modoboa/core/forms.py @@ -1,5 +1,8 @@ """Core forms.""" +# import pyotp +import oath + from django import forms from django.contrib.auth import ( forms as auth_forms, get_user_model, password_validation @@ -125,3 +128,22 @@ class PasswordResetForm(auth_forms.PasswordResetForm): super(PasswordResetForm, self).send_mail( subject_template_name, email_template_name, context, from_email, to_email, html_email_template_name) + + +class VerifySMSCodeForm(forms.Form): + """A form to verify a code received by SMS.""" + + code = forms.CharField( + label=ugettext_lazy("Verification code"), + widget=forms.widgets.TextInput(attrs={"class": "form-control"}) + ) + + def __init__(self, *args, **kwargs): + self.totp_secret = kwargs.pop("totp_secret") + super().__init__(*args, **kwargs) + + def clean_code(self): + code = self.cleaned_data["code"] + if not oath.accept_totp(self.totp_secret, code)[0]: + raise forms.ValidationError(_("Invalid code")) + return code diff --git a/modoboa/core/migrations/0020_auto_20200421_0851.py b/modoboa/core/migrations/0020_auto_20200421_0851.py new file mode 100644 index 000000000..1acbe11be --- /dev/null +++ b/modoboa/core/migrations/0020_auto_20200421_0851.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.9 on 2020-04-21 06:51 + +from django.db import migrations +import phonenumber_field.modelfields + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0019_auto_20200121_1659'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='phone_number', + field=phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, null=True, region=None, verbose_name='Phone number'), + ), + ] diff --git a/modoboa/core/models.py b/modoboa/core/models.py index f51218dd1..f0e54ccc0 100644 --- a/modoboa/core/models.py +++ b/modoboa/core/models.py @@ -3,13 +3,7 @@ import re from email.header import Header -import jsonfield -from reversion import revisions as reversion - from django.conf import settings -from django.contrib.auth.models import AbstractUser, Group -from django.contrib.contenttypes.fields import GenericForeignKey -from django.contrib.contenttypes.models import ContentType from django.db import models from django.urls import reverse from django.utils.encoding import ( @@ -18,6 +12,14 @@ from django.utils.encoding import ( from django.utils.functional import cached_property from django.utils.translation import ugettext as _, ugettext_lazy +from django.contrib.auth.models import AbstractUser, Group +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType + +import jsonfield +from phonenumber_field.modelfields import PhoneNumberField +from reversion import revisions as reversion + from modoboa.core.password_hashers import get_password_hasher from modoboa.lib.exceptions import ( BadRequest, Conflict, InternalError, PermDeniedException @@ -64,8 +66,8 @@ class User(AbstractUser): "Prefered language to display pages." ) ) - phone_number = models.CharField( - ugettext_lazy("Phone number"), max_length=128, blank=True, null=True) + phone_number = PhoneNumberField( + ugettext_lazy("Phone number"), blank=True, null=True) secondary_email = models.EmailField( ugettext_lazy("Secondary email"), max_length=254, blank=True, null=True, diff --git a/modoboa/core/sms_backends/__init__.py b/modoboa/core/sms_backends/__init__.py new file mode 100644 index 000000000..f3814a199 --- /dev/null +++ b/modoboa/core/sms_backends/__init__.py @@ -0,0 +1,79 @@ +"""SMS backends.""" + +from importlib import import_module + +from .. import constants + + +class SMSBackend: + """Base class of every SMS backend.""" + + settings = {} + visibility_rules = {} + + def __init__(self, params): + """Default constructor.""" + self._params = params + + def send(self, text, recipients): + """Send a new SMS to given recipients.""" + raise NotImplementedError + + +def get_backend_class(name): + """Return class for given backend.""" + backend_module = import_module( + "modoboa.core.sms_backends.{}".format(name)) + try: + backend_class = getattr( + backend_module, "{}Backend".format(name.upper())) + except AttributeError: + return None + else: + return backend_class + + +def get_backend_settings(name): + """Return settings of given backend.""" + backend_class = get_backend_class(name) + if not backend_class: + return None + return backend_class.settings + + +def get_all_backend_settings(): + """Return settings of all backends.""" + settings = {} + for backend in constants.SMS_BACKENDS: + name = backend[0] + if not name: + continue + b_settings = get_backend_settings(name) + if b_settings: + settings.update(b_settings) + return settings + + +def get_all_backend_visibility_rules(): + """Return visibility rules of all backends.""" + rules = {} + for backend in constants.SMS_BACKENDS: + name = backend[0] + if not name: + continue + backend_class = get_backend_class(name) + if not backend_class: + continue + rules.update(backend_class.visibility_rules) + return rules + + +def get_active_backend(parameters): + """Return active SMS backend.""" + name = parameters.get_value("sms_provider") + if not name: + return None + backend_class = get_backend_class(name) + if not backend_class: + return None + return backend_class(parameters) diff --git a/modoboa/core/sms_backends/dummy.py b/modoboa/core/sms_backends/dummy.py new file mode 100644 index 000000000..595478cb7 --- /dev/null +++ b/modoboa/core/sms_backends/dummy.py @@ -0,0 +1,12 @@ +"""Dummy SMS backend.""" + +from . import SMSBackend + + +class DUMMYBackend(SMSBackend): + """Dummy backend to use with tests.""" + + def send(self, text, recipients): + """Print text on stdout.""" + print(text) + return True diff --git a/modoboa/core/sms_backends/ovh.py b/modoboa/core/sms_backends/ovh.py new file mode 100644 index 000000000..db40c9bc1 --- /dev/null +++ b/modoboa/core/sms_backends/ovh.py @@ -0,0 +1,86 @@ +"""OVH SMS backend.""" + +import ovh + +from django import forms +from django.utils.functional import cached_property +from django.utils.translation import ugettext_lazy as _ + +from . import SMSBackend + + +class OVHBackend(SMSBackend): + """OVH SMS backend class.""" + + settings = { + "sms_ovh_endpoint": { + "type": forms.ChoiceField, + "attrs": { + "label": _("API endpoint"), + "initial": "ovh-eu", + "choices": ( + ("ovh-eu", _("OVH Europe")), + ("ovh-us", _("OVH US")), + ("ovh-ca", _("OVH North-America")), + ("soyoustart-eu", _("So you Start Europe")), + ("soyoustart-ca", _("So you Start North America")), + ("kimsufi-eu", _("Kimsufi Europe")), + ("kimsufi-ca", _("Kimsufi North America")), + ) + } + }, + "sms_ovh_application_key": { + "type": forms.CharField, + "attrs": { + "label": _("Application key"), + "required": False + } + }, + "sms_ovh_application_secret": { + "type": forms.CharField, + "attrs": { + "label": _("Application secret"), + "widget": forms.widgets.PasswordInput(render_value=True), + "required": False + } + }, + "sms_ovh_consumer_key": { + "type": forms.CharField, + "attrs": { + "label": _("Consumer key"), + "widget": forms.widgets.PasswordInput(render_value=True), + "required": False + } + } + } + + visibility_rules = { + "sms_ovh_endpoint": "sms_provider=ovh", + "sms_ovh_application_key": "sms_provider=ovh", + "sms_ovh_application_secret": "sms_provider=ovh", + "sms_ovh_consumer_key": "sms_provider=ovh" + } + + @cached_property + def client(self): + return ovh.Client( + endpoint=self._params.get_value("sms_ovh_endpoint"), + application_key=self._params.get_value("sms_ovh_application_key"), + application_secret=self._params.get_value("sms_ovh_application_secret"), + consumer_key=self._params.get_value("sms_ovh_consumer_key") + ) + + def send(self, text, recipients): + """Send a new SMS to given recipients.""" + services = self.client.get("/sms") + result = self.client.post( + "/sms/{}/jobs".format(services[0]), + message=text, + receivers=recipients, + priority="high", + noStopClause=True, + senderForResponse=True + ) + if result["totalCreditsRemoved"] != len(recipients): + return False + return True diff --git a/modoboa/core/static/core/img/qrcode.svg b/modoboa/core/static/core/img/qrcode.svg new file mode 100644 index 000000000..692f1204c --- /dev/null +++ b/modoboa/core/static/core/img/qrcode.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/modoboa/core/tests/test_authentication.py b/modoboa/core/tests/test_authentication.py index 1aec44c21..e16f800a1 100644 --- a/modoboa/core/tests/test_authentication.py +++ b/modoboa/core/tests/test_authentication.py @@ -192,6 +192,7 @@ class PasswordResetTestCase(ModoTestCase): super(PasswordResetTestCase, cls).setUpTestData() cls.account_ok = factories.UserFactory( username="user@test.com", secondary_email="test@ext.com", + phone_number="+33612345678", groups=("SimpleUsers",) ) cls.account_ko = factories.UserFactory( @@ -220,6 +221,26 @@ class PasswordResetTestCase(ModoTestCase): "We've emailed you instructions for setting your password") self.assertEqual(len(mail.outbox), 0) + @mock.patch("ovh.Client.get") + @mock.patch("ovh.Client.post") + def test_reset_password_sms(self, client_post, client_get): + """Test reset password by SMS.""" + client_get.return_value = ["service"] + client_post.return_value = {"totalCreditsRemoved": 1} + self.set_global_parameters({ + "sms_password_recovery": True, + "sms_provider": "ovh", + "sms_ovh_application_key": "key", + "sms_ovh_application_secret": "secret", + "sms_ovh_consumer_key": "consumer" + }) + self.client.logout() + url = reverse("password_reset") + data = {"email": self.account_ok.email} + response = self.client.post(url, data) + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, reverse("password_reset_confirm_code")) + @skipIf(NO_SMTP, "No SMTP server available") @override_settings(AUTHENTICATION_BACKENDS=( diff --git a/modoboa/core/tests/test_views.py b/modoboa/core/tests/test_views.py index bed51b1a5..26d25808a 100644 --- a/modoboa/core/tests/test_views.py +++ b/modoboa/core/tests/test_views.py @@ -84,7 +84,11 @@ SETTINGS_SAMPLE = { "core-ldap_enable_import": False, "core-ldap_import_search_base": "", "core-ldap_import_search_filter": "", - "core-ldap_import_username_attr": "cn" + "core-ldap_import_username_attr": "cn", + "core-sms_password_recovery": False, + "core-sms_provider": "", + "core-sms_ovh_endpoint": "ovh-eu", + "core-password_recovery_msg": "" } diff --git a/modoboa/core/views/__init__.py b/modoboa/core/views/__init__.py index f0300f63b..3a4def64a 100644 --- a/modoboa/core/views/__init__.py +++ b/modoboa/core/views/__init__.py @@ -4,7 +4,10 @@ from .admin import ( check_top_notifications, information, logs, logs_page, parameters, viewsettings ) -from .auth import PasswordResetView, dologin, dologout +from .auth import ( + PasswordResetView, dologin, dologout, VerifySMSCodeView, + ResendSMSCodeView +) from .base import RootDispatchView from .dashboard import DashboardView from .user import api_access, index, preferences, profile @@ -12,7 +15,9 @@ from .user import api_access, index, preferences, profile __all__ = [ "DashboardView", "PasswordResetView", + "ResendSMSCodeView", "RootDispatchView", + "VerifySMSCodeView", "api_access", "check_top_notifications", "dologin", diff --git a/modoboa/core/views/auth.py b/modoboa/core/views/auth.py index d135eef8f..b466cc5a7 100644 --- a/modoboa/core/views/auth.py +++ b/modoboa/core/views/auth.py @@ -2,19 +2,32 @@ import logging -from django.contrib.auth import ( - authenticate, login, logout, views as auth_views -) -from django.http import HttpResponse, HttpResponseRedirect +import oath + +from django.http import HttpResponse, HttpResponseRedirect, Http404 from django.template.loader import render_to_string from django.urls import reverse from django.utils import translation +from django.utils.encoding import force_bytes +from django.utils.http import urlsafe_base64_encode from django.utils.translation import ugettext as _ +from django.views import generic from django.views.decorators.cache import never_cache +from django.contrib.auth import ( + authenticate, login, logout, views as auth_views +) +from django.contrib.auth.tokens import default_token_generator + +from braces.views import JSONResponseMixin + from modoboa.core import forms from modoboa.core.password_hashers import get_password_hasher +from modoboa.lib import cryptutils from modoboa.parameters import tools as param_tools + +from .. import models +from .. import sms_backends from .. import signals from .base import find_nextlocation @@ -125,3 +138,94 @@ class PasswordResetView(auth_views.PasswordResetView): self.from_email = request.localconfig.parameters.get_value( "sender_address" ) + + def get_context_data(self, **kwargs): + """Include help text.""" + context = super().get_context_data(**kwargs) + context["announcement"] = ( + self.request.localconfig.parameters + .get_value("password_recovery_msg") + ) + return context + + def form_valid(self, form): + """Redirect to code verification page if needed.""" + sms_password_recovery = ( + self.request.localconfig.parameters + .get_value("sms_password_recovery") + ) + if not sms_password_recovery: + return super().form_valid(form) + user = models.User._default_manager.filter( + email=form.cleaned_data["email"], phone_number__isnull=False + ).first() + if not user: + # Fallback to email + return super().form_valid(form) + backend = sms_backends.get_active_backend( + self.request.localconfig.parameters) + secret = cryptutils.random_hex_key(20) + code = oath.totp(secret) + text = _( + "Please use the following code to recover your Modoboa password: {}" + .format(code) + ) + if not backend.send(text, [user.phone_number]): + return super().form_valid(form) + self.request.session["user_pk"] = user.pk + self.request.session["totp_secret"] = secret + return HttpResponseRedirect(reverse("password_reset_confirm_code")) + + +class VerifySMSCodeView(generic.FormView): + """View to verify a code received by SMS.""" + + form_class = forms.VerifySMSCodeForm + template_name = "registration/password_reset_confirm_code.html" + + def get_form_kwargs(self): + """Include totp secret in kwargs.""" + kwargs = super().get_form_kwargs() + try: + kwargs.update({"totp_secret": self.request.session["totp_secret"]}) + except KeyError: + raise Http404 + return kwargs + + def form_valid(self, form): + """Redirect to reset password form.""" + user = models.User.objects.get(pk=self.request.session.pop("user_pk")) + self.request.session.pop("totp_secret") + token = default_token_generator.make_token(user) + uid = urlsafe_base64_encode(force_bytes(user.pk)) + url = reverse("password_reset_confirm", args=[uid, token]) + return HttpResponseRedirect(url) + + +class ResendSMSCodeView(JSONResponseMixin, generic.View): + """A view to resend validation code.""" + + def get(self, request, *args, **kwargs): + sms_password_recovery = ( + self.request.localconfig.parameters + .get_value("sms_password_recovery") + ) + if not sms_password_recovery: + raise Http404 + try: + user = models.User._default_manager.get( + pk=self.request.session["user_pk"]) + except KeyError: + raise Http404 + backend = sms_backends.get_active_backend( + self.request.localconfig.parameters) + secret = cryptutils.random_hex_key(20) + code = oath.totp(secret) + text = _( + "Please use the following code to recover your Modoboa password: {}" + .format(code) + ) + if not backend.send(text, [user.phone_number]): + raise Http404 + self.request.session["totp_secret"] = secret + return self.render_json_response({"status": "ok"}) diff --git a/modoboa/lib/cryptutils.py b/modoboa/lib/cryptutils.py index 8ba6b36b6..d413af9b4 100644 --- a/modoboa/lib/cryptutils.py +++ b/modoboa/lib/cryptutils.py @@ -1,8 +1,13 @@ """Crypto related utilities.""" import base64 +import time from cryptography.fernet import Fernet +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.twofactor import InvalidToken +from cryptography.hazmat.primitives.twofactor.totp import TOTP +from cryptography.hazmat.primitives.hashes import SHA1 from django.conf import settings from django.utils.crypto import get_random_string @@ -15,6 +20,12 @@ def random_key(length=16): return get_random_string(length, chars) +def random_hex_key(length=16): + """Generate a new hexadecimal key.""" + chars = "abcdefABCDEF0123456789" + return get_random_string(length, chars) + + def _get_fernet(): """Create a Fernet instance.""" secret_key = base64.urlsafe_b64encode(smart_bytes(settings.SECRET_KEY[:32])) diff --git a/modoboa/parameters/forms.py b/modoboa/parameters/forms.py index 20e16aa85..f35949b25 100644 --- a/modoboa/parameters/forms.py +++ b/modoboa/parameters/forms.py @@ -22,18 +22,28 @@ class GenericParametersForm(forms.Form): load_values_from_db = kwargs.pop("load_values_from_db", True) super(GenericParametersForm, self).__init__(*args, **kwargs) + self._add_dynamic_fields() + self.visirules = {} if self.visibility_rules is not None: - for key, rule in list(self.visibility_rules.items()): - field, value = rule.split("=") - visibility = { - "field": "id_%s-%s" % (self.app, field), "value": value - } - self.visirules["%s-%s" % (self.app, key)] = visibility + self._add_visibilty_rules(self.visibility_rules) if not args and load_values_from_db: self._load_initial_values() + def _add_visibilty_rules(self, rules): + """Add visibility rules to this form.""" + for key, rule in list(rules.items()): + field, value = rule.split("=") + visibility = { + "field": "id_%s-%s" % (self.app, field), "value": value + } + self.visirules["%s-%s" % (self.app, key)] = visibility + + def _add_dynamic_fields(self): + """Add dynamic fields to this form.""" + pass + def _load_initial_values(self): raise NotImplementedError diff --git a/modoboa/templates/registration/base.html b/modoboa/templates/registration/base.html index 774264c14..8c1343368 100644 --- a/modoboa/templates/registration/base.html +++ b/modoboa/templates/registration/base.html @@ -31,7 +31,7 @@ {% block sub_content %}{% endblock %} - + {% if TEMPLATE_DEBUG %} diff --git a/modoboa/templates/registration/password_reset_confirm_code.html b/modoboa/templates/registration/password_reset_confirm_code.html new file mode 100644 index 000000000..0e4fd9374 --- /dev/null +++ b/modoboa/templates/registration/password_reset_confirm_code.html @@ -0,0 +1,45 @@ +{% extends "registration/base.html" %} + +{% load i18n form_tags %} + +{% block main_content %} +
+ {% csrf_token %} +
+
+ + {{ form.code }} + {% if form.code.errors %} +

+ {% for error in form.code.errors %} + {{ error }} + {% endfor %} +

+ {% endif %} +

+ {% blocktrans trimmed %} + Enter the code you've just received by SMS + {% endblocktrans %} +

+
+ + + {% trans "Resend code" %} + +
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/modoboa/templates/registration/password_reset_form.html b/modoboa/templates/registration/password_reset_form.html index 04d1571ce..672e40bea 100644 --- a/modoboa/templates/registration/password_reset_form.html +++ b/modoboa/templates/registration/password_reset_form.html @@ -3,12 +3,31 @@ {% load i18n form_tags %} {% block main_content %} + + {% if announcement %} +
+
+
+ {{ announcement }} +
+
+
+ {% endif %} +

{% trans "Forgot password?" %}

{% csrf_token %} {% render_field form.email %} +
+
+
+ {% trans "Please fill-in your primary email address" %} +
+
+
+ {% endblock %} diff --git a/modoboa/templates/swagger-ui.html b/modoboa/templates/swagger-ui.html index 3cbdfa5b0..efbd02f35 100644 --- a/modoboa/templates/swagger-ui.html +++ b/modoboa/templates/swagger-ui.html @@ -35,19 +35,23 @@
diff --git a/modoboa/urls.py b/modoboa/urls.py index 4239a412f..3b4a80c28 100644 --- a/modoboa/urls.py +++ b/modoboa/urls.py @@ -35,6 +35,12 @@ urlpatterns = [ path('accounts/password_reset/done/', auth_views.PasswordResetDoneView.as_view(), name="password_reset_done"), + path('reset/confirm_code/', + core_views.VerifySMSCodeView.as_view(), + name="password_reset_confirm_code"), + path('reset/resend_code/', + core_views.ResendSMSCodeView.as_view(), + name="password_reset_resend_code"), path('reset///', auth_views.PasswordResetConfirmView.as_view(), name="password_reset_confirm"), diff --git a/requirements.txt b/requirements.txt index edb49f792..2c8dcfa9b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ django>=2.2,<2.3 django-braces==1.14.0 django-ckeditor==5.8.0 +django-phonenumber-field[phonenumbers]==4.0.0 django-reversion==3.0.5 django-xforwardedfor-middleware==2.0 @@ -24,3 +25,5 @@ pytz requests lxml chardet +ovh +oath