mirror of
https://github.com/jwinterm/modoboa.git
synced 2026-10-17 05:41:25 +02:00
+37
-2
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{% for message in messages %}
|
||||
<tr>
|
||||
<td name="name">{{ message.id }}</td>
|
||||
<td>{{ message.date }}</td>
|
||||
<td>{{ message.from }}</td>
|
||||
<td>{{ message.to }}</td>
|
||||
<td name="quota">
|
||||
{{ message.status }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -0,0 +1,6 @@
|
||||
{% load i18n %}
|
||||
<th class="sortable" data-sort_order="name">{% trans "ID" %}</th>
|
||||
<th class="sortable" data-sort_order="quota">{% trans "Date" %}</th>
|
||||
<th class="sortable" data-sort_order="quota_value__used">{% trans "From" %}</th>
|
||||
<th class="sortable" data-sort_order="allocated_quota">{% trans "To" %}</th>
|
||||
<th class="sortable" data-sort_order="quota_usage" id="quotabar">{% trans "Status" %}</th>
|
||||
@@ -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):
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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")))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
+10
-8
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 40 KiB |
@@ -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=(
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
+108
-4
@@ -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"})
|
||||
|
||||
@@ -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]))
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
{% block sub_content %}{% endblock %}
|
||||
</div>
|
||||
<!-- Javascript -->
|
||||
<script type="text/javascript" src="{% static 'jquery/jquery.js' %}"></script>
|
||||
<script type="text/javascript" src="{% static 'jquery/jquery.min.js' %}"></script>
|
||||
<script type="text/javascript" src="{% static 'bootstrap/dist/js/bootstrap.min.js' %}"></script>
|
||||
{% if TEMPLATE_DEBUG %}
|
||||
<script src="//maxcdn.bootstrapcdn.com/bootlint/0.4.0/bootlint.min.js"></script>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
{% extends "registration/base.html" %}
|
||||
|
||||
{% load i18n form_tags %}
|
||||
|
||||
{% block main_content %}
|
||||
<form method="post" style="margin-top: 20px">
|
||||
{% csrf_token %}
|
||||
<div class="col-sm-offset-4 col-sm-5">
|
||||
<div class="form-group{% if form.code.errors %} has-error{% endif %}">
|
||||
<label for="id_code">{{ form.code.label }} *</label>
|
||||
{{ form.code }}
|
||||
{% if form.code.errors %}
|
||||
<p class="help-block">
|
||||
{% for error in form.code.errors %}
|
||||
{{ error }}
|
||||
{% endfor %}
|
||||
</p>
|
||||
{% endif %}
|
||||
<p class="help-block">
|
||||
{% blocktrans trimmed %}
|
||||
Enter the code you've just received by SMS
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary col-lg-12">{% trans "Submit" %}</button>
|
||||
<a class="pull-right" id="resend_code" href="{% url 'password_reset_resend_code' %}">
|
||||
{% trans "Resend code" %}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script type="text/javascript">
|
||||
$(document).ready(function () {
|
||||
$('#resend_code').click(function (evt) {
|
||||
evt.preventDefault();
|
||||
var $this = $(this);
|
||||
$.get($this.attr('href')).done(function (response) {
|
||||
alert('The code has been sent again');
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -3,12 +3,31 @@
|
||||
{% load i18n form_tags %}
|
||||
|
||||
{% block main_content %}
|
||||
|
||||
{% if announcement %}
|
||||
<div class="row">
|
||||
<div class="col-xs-12 col-sm-offset-3 col-sm-7">
|
||||
<div class="alert alert-warning text-center">
|
||||
{{ announcement }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<h3 class="text-center">{% trans "Forgot password?" %}</h3>
|
||||
<form class="form-horizontal" method="post" style="margin-top: 20px">
|
||||
{% csrf_token %}
|
||||
{% render_field form.email %}
|
||||
<div class="row">
|
||||
<div class="col-sm-offset-4 col-sm-5">
|
||||
<div class="alert alert-info">
|
||||
{% trans "Please fill-in your primary email address" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-offset-4 col-sm-5">
|
||||
<button type="submit" class="btn btn-primary col-lg-12">{% trans "Submit" %}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -35,19 +35,23 @@
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="{% static 'swagger-ui/dist/swagger-ui-bundle.js' %}"></script>
|
||||
<script>
|
||||
window.onload = function() {
|
||||
window.onload = function() {
|
||||
const ui = SwaggerUIBundle({
|
||||
url: "{% url 'openapi_schema' %}",
|
||||
dom_id: '#swagger-ui',
|
||||
deepLinking: true,
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis
|
||||
],
|
||||
layout: "BaseLayout"
|
||||
url: "{% url 'openapi_schema' %}",
|
||||
dom_id: '#swagger-ui',
|
||||
deepLinking: true,
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis
|
||||
],
|
||||
layout: "BaseLayout",
|
||||
requestInterceptor: (request) => {
|
||||
request.headers['X-CSRFToken'] = "{{ csrf_token }}"
|
||||
return request;
|
||||
}
|
||||
});
|
||||
|
||||
window.ui = ui;
|
||||
};
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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/<uidb64>/<token>/',
|
||||
auth_views.PasswordResetConfirmView.as_view(),
|
||||
name="password_reset_confirm"),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user