Retrieve password by SMS.

see #1877
This commit is contained in:
Antoine Nguyen
2020-04-21 11:34:29 +02:00
parent e34345a2b7
commit cb0197c609
26 changed files with 620 additions and 35 deletions
+37 -2
View File
@@ -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):
"""
+6
View File
@@ -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>
+63 -2
View File
@@ -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 %}
+9
View File
@@ -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")))
+22
View File
@@ -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
View File
@@ -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,
+79
View File
@@ -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)
+12
View File
@@ -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
+86
View File
@@ -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

+21
View File
@@ -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=(
+5 -1
View File
@@ -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": ""
}
+6 -1
View File
@@ -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
View File
@@ -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"})
+11
View File
@@ -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]))
+16 -6
View File
@@ -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
+1 -1
View File
@@ -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 %}
+13 -9
View File
@@ -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>
+6
View File
@@ -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"),
+3
View File
@@ -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