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 %}
+
+{% 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?" %}
+
{% 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 @@